From 688ff409b834c77b6e6ee5663c87714ccfaf532b Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:16:16 +1200 Subject: [PATCH 01/12] Reduce HDF5 memory retention and harden Mass template teardown Move HDF5 entity metadata from the data runnable into AgentDataSubsystem (CachedHdf5Entities) so entity info remains available after runnable teardown. Update SetEntityInfoByIndex and SetEntityRenderingByIndex to read from the cached subsystem array. Release the raw HDF5 sample buffer earlier in Run() to reduce peak memory usage. In MassEntitySpawnSubsystem: - add RegisteredPedestrianTemplateID - explicitly DestroyTemplate on file switch to prevent stale FSimulationFragment references from being retained in the TemplateRegistry - clear SharedSimulationFragment before recreating it - use MoveTemp when creating shared fragments Also cache HDF5 entities during BuildPedestrianMovementFragmentData, reset JSONObject where appropriate, and apply minor cleanup/comment improvements. Note: This change mitigates the current suspected lifetime/memory issue, but it does not complete the larger loading/parsing overhaul. A follow-up refactor is planned once the current issue is resolved to separate format-specific loading from the shared data parsing path. That will allow JSON, HDF5, and future formats to produce a consistent intermediate representation, reducing duplicated logic, limiting format-specific assumptions, and making eventual streaming support significantly easier to implement. AI transparency: Claude assisted with investigation of the suspected issue and potential fixes. ChatGPT was used to polish the wording of this commit message. All code changes and final review decisions remain mine. --- .../MassAI/SubSystems/AgentDataSubsystem.cpp | 29 +++-- .../SubSystems/MassEntitySpawnSubsystem.cpp | 103 ++++++++++++------ .../MassAI/SubSystems/AgentDataSubsystem.h | 10 +- .../SubSystems/MassEntitySpawnSubsystem.h | 3 + 4 files changed, 98 insertions(+), 47 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp index 4daf680a1..b9769db4f 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp @@ -299,11 +299,10 @@ void UAgentDataSubsystem::SetEntityInfoByIndex(int32 Index, FEntityInfoFragment& return; } - // Check if we have HDF5 data loaded via the runnable - if (JsonDataRunnable && JsonDataRunnable->SimulationFileType == ESimulationFileType::ESFT_HDF5) + // Check if we have HDF5 entity data cached (moved out of runnable before it was torn down) + if (CachedHdf5Entities.Num() > 0) { - const TArray& Entities = JsonDataRunnable->Hdf5Data.Entities; - if (!Entities.IsValidIndex(Index)) + if (!CachedHdf5Entities.IsValidIndex(Index)) { ReportAgentDataError(this, TEXT("Entity data missing"), @@ -312,7 +311,7 @@ void UAgentDataSubsystem::SetEntityInfoByIndex(int32 Index, FEntityInfoFragment& return; } - const FHdf5EntityData& Entity = Entities[Index]; + const FHdf5EntityData& Entity = CachedHdf5Entities[Index]; EntityInfoFragToUpdate.EntityID = Entity.Id; EntityInfoFragToUpdate.EntityName = Entity.Name; EntityInfoFragToUpdate.EntitySimTimeS = FString::SanitizeFloat(Entity.SimTimeS); @@ -364,11 +363,10 @@ void UAgentDataSubsystem::SetEntityRenderingByIndex(int32 Index, FString AgentName; - // Check if we have HDF5 data loaded via the runnable - if (JsonDataRunnable && JsonDataRunnable->SimulationFileType == ESimulationFileType::ESFT_HDF5) + // Check if we have HDF5 entity data cached (moved out of runnable before it was torn down) + if (CachedHdf5Entities.Num() > 0) { - const TArray& Entities = JsonDataRunnable->Hdf5Data.Entities; - if (!Entities.IsValidIndex(Index)) + if (!CachedHdf5Entities.IsValidIndex(Index)) { ReportAgentDataError(this, TEXT("Entity data missing"), @@ -377,7 +375,7 @@ void UAgentDataSubsystem::SetEntityRenderingByIndex(int32 Index, return; } - AgentName = Entities[Index].Name; + AgentName = CachedHdf5Entities[Index].Name; } else { @@ -1753,6 +1751,17 @@ uint32 FProcessSimulationDataRunnable:: Run() return 0; } + // Free raw sample buffer now — RunSimulationDataGatheringLoop has fully consumed it and + // the local SamplesByTimestep (which held raw pointers into Samples) is out of scope. + // Hdf5Data.Entities must NOT be freed here — PedestrianInitializeMOP accesses it on the + // game thread after BatchCreateEntities, before BuildPedestrianMovementFragmentData moves + // it into AgentDataSubsystem::CachedHdf5Entities. + if (SimulationFileType == ESimulationFileType::ESFT_HDF5) + { + Hdf5Data.Samples.Empty(); + Hdf5Data.Samples.Shrink(); + } + // Send the final progress and completion events FinalizeProgress(); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index d26fefa79..fe3035061 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -11,14 +11,14 @@ * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. + * all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ @@ -67,14 +67,14 @@ void UMassEntitySpawnSubsystem::Initialize(FSubsystemCollectionBase& Collection) // Add the AgentDataSubsystem to the collection Dependency AgentDataSubsystem = Collection.InitializeDependency(); - + // Get the entity manager from the MassSubsystem //EntityManager = GetWorld()->GetSubsystem()->GetMutableEntityManager().AsShared(); // If we have other subsystems that we depend on we can initialize them here before super Super::Initialize(Collection); - // Get the Game Instance + // Get the Game Instance if(UProjectMobiusGameInstance* GameInst = GetMobiusGameInstance(GetWorld())) { // Bind the required Game Instance Delegates @@ -107,7 +107,7 @@ void UMassEntitySpawnSubsystem::Deinitialize() void UMassEntitySpawnSubsystem::OnWorldBeginPlay(UWorld& InWorld) { Super::OnWorldBeginPlay(InWorld); - + // Ensure rep actor exists BEFORE Mass creates entities if (UWorld* World = GetWorld()) { @@ -124,7 +124,7 @@ void UMassEntitySpawnSubsystem::OnWorldBeginPlay(UWorld& InWorld) void UMassEntitySpawnSubsystem::SpawnMassEntityPedestrians(int32 NumberOfPedestriansToSpawn, FMassArchetypeSharedFragmentValues ArchetypeSharedFragmentValues) { auto PedestrianArchetypeHandle = CreatePedestrianArchetype(); - + // check shared fragment values are sorted and sort if not // -- this has been debugged and is redundant but in place as a safety measure if (!ArchetypeSharedFragmentValues.IsSorted()) @@ -160,7 +160,7 @@ void UMassEntitySpawnSubsystem::SpawnMaxPedestrians(FMassArchetypeSharedFragment { UE_LOG(LogTemp, Warning, TEXT("Max Pedestrians is less than 0, likely a bad data file.")); } - + } void UMassEntitySpawnSubsystem::DestroySpawnedPedestrians(TConstArrayView EntitiesToDestroy) @@ -233,9 +233,12 @@ FMassArchetypeHandle UMassEntitySpawnSubsystem::CreatePedestrianArchetype() // Get this traits template id FMassEntityTemplateID DebugEntityLocationTraitID = PedestrianLocationTraitBuildContext.GetTemplateID(); - + + // Remember the ID so CreatePedestrianTemplateData can call DestroyTemplate on file switch + RegisteredPedestrianTemplateID = DebugEntityLocationTraitID; + TemplateRegistryInstance.FindOrAddTemplate(DebugEntityLocationTraitID, MoveTemp(PedestrianTemplateData)); - + auto PedestrianArchetypeHandle = EntityManager->CreateArchetype(PedestrianTemplateData.GetCompositionDescriptor()); return PedestrianArchetypeHandle; @@ -245,22 +248,22 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() { // get the mobius widget subsystem auto LoadingSubsystem = GetWorld()->GetSubsystem(); - + // check if the widget subsystem is valid if (LoadingSubsystem) { FString LoadingText = FString::Printf(TEXT("Clearing Old Data...")); - + // Set the loading text and title LoadingSubsystem->SetLoadingText(true, LoadingText); } - + // Cleanup any existing runnable to avoid memory leaks AgentDataRunnableCleanup(AgentDataSubsystem->JsonDataRunnable); bHasResetFlowCounters = false; - + // as our capsule objects are bound to the world and the world is never destroyed, we need to ensure that the // capsule components are cleared and marked for destruction so that we don't have memory leaks for (auto& EntityHandle : SpawnedEntityPedestrianHandles) @@ -271,21 +274,32 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() Fragment.Capsule->DestroyComponent(); } } - + // Destroy any existing spawned pedestrians and clear the Niagara simulation DestroyAllSpawnedPedestrians(); ClearNiagaraSim(); // Empty out the handles array SpawnedEntityPedestrianHandles.Empty(); - + // We have to force a garbage collection here to ensure that the old data is cleared from memory before new // data is created CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + // Destroy old template entry in the registry — FindOrAddTemplate never replaces existing + // entries (UE5 returns the old one and silently discards new data), so we must explicitly + // remove it. This drops the registry's FSharedStruct ref to the old FSimulationFragment. + // Safe because all entities using this template have already been destroyed above. + TemplateRegistryInstance.DestroyTemplate(RegisteredPedestrianTemplateID); + RegisteredPedestrianTemplateID.Invalidate(); + // Reset the template data PedestrianTemplateData = FMassEntityTemplateData(); - + + // Drop our ref to the old simulation fragment so it can be freed now that the + // TemplateRegistryInstance has also released its ref. + SharedSimulationFragment = FSharedStruct(); + LoadPedestrianData(); } @@ -293,25 +307,25 @@ void UMassEntitySpawnSubsystem::LoadPedestrianData() { // get the mobius widget subsystem auto LoadingSubsystem = GetWorld()->GetSubsystem(); - + // check if the widget subsystem is valid if (LoadingSubsystem) { FString LoadingText = FString::Printf(TEXT("Fetching Pedestrian Data File...")); - + // Set the loading text and title LoadingSubsystem->SetLoadingText(true, LoadingText); } - + FString JSONDataFile = ""; - // Get the Game Instance + // Get the Game Instance if(UProjectMobiusGameInstance* GameInst = GetMobiusGameInstance(GetWorld())) { // do we have a file to use from the game instance JSONDataFile = GameInst->GetPedestrianDataFilePath(); } - + // Check Agent Data Subsystem is valid if (!AgentDataSubsystem) { @@ -346,27 +360,27 @@ void UMassEntitySpawnSubsystem::LoadPedestrianData() FString FileName = FPaths::GetCleanFilename(JSONDataFile); FString LoadingText = FString::Printf(TEXT("Loading File: %s"), *FileName); - + // Set the loading text and title LoadingSubsystem->SetLoadingText(true, LoadingText); } - + } void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() { UE_LOG(LogTemp, Warning, TEXT("Building Pedestrian Movement Fragment Data")); - + // get the mobius widget subsystem auto LoadingSubsystem = GetWorld()->GetSubsystem(); - + // check if the widget subsystem is valid if (LoadingSubsystem) { FString LoadingText = FString::Printf(TEXT("Building Pedestrian Movement AI Data...")); - + // Set the loading text and title LoadingSubsystem->SetLoadingText(true, LoadingText); LoadingSubsystem->BroadcastNewLoadPercent(0.0f); @@ -383,6 +397,19 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() JSONObjectLocal = MoveTemp(AgentDataSubsystem->JsonDataRunnable->JSONObject); TimeBetweenStepsLocal = AgentDataSubsystem->JsonDataRunnable->TimeBetweenSteps; LoadedFileType = AgentDataSubsystem->JsonDataRunnable->SimulationFileType; + + // Cache HDF5 entity metadata before the runnable is torn down. + // PedestrianInitializeMOP fires after SpawnMaxPedestrians destroys the runnable, + // so SetEntityInfoByIndex / SetEntityRenderingByIndex must read from here instead. + if (LoadedFileType == ESimulationFileType::ESFT_HDF5) + { + AgentDataSubsystem->CachedHdf5Entities = MoveTemp(AgentDataSubsystem->JsonDataRunnable->Hdf5Data.Entities); + } + else + { + AgentDataSubsystem->CachedHdf5Entities.Empty(); + AgentDataSubsystem->CachedHdf5Entities.Shrink(); + } } //UE_LOG(LogTemp, Warning, TEXT("Building Pedestrian Movement Fragment Data")); @@ -407,6 +434,10 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() { AgentDataSubsystem->JSONObject = JSONObjectLocal; } + else + { + AgentDataSubsystem->JSONObject.Reset(); + } // Get Time Dilation from the ProjectMobius Game Instance UTimeDilationSubSystem* TimeDilationSubSystem = GetWorld()->GetSubsystem(); @@ -417,16 +448,16 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() // Update the total time for the Time Dilation Subsystem - which also updates the max time steps TimeDilationSubSystem->UpdateTotalTime(SimulationFragment.MaxTime); - auto SharedSimulationFragmentData = FSharedStruct::Make(SimulationFragment); + auto SharedSimulationFragmentData = FSharedStruct::Make(MoveTemp(SimulationFragment)); SharedSimulationFragment = SharedSimulationFragmentData; // Add the shared fragment to the build context PedestrianTemplateData.AddSharedFragment(SharedSimulationFragmentData); - + // Create the Pedestrian Representation Fragment Data BuildPedestrianRepresentationFragmentData(); - + auto ArchetypeSharedFragmentValues = PedestrianTemplateData.GetSharedFragmentValues(); // check shared fragment values are sorted and sort if not @@ -437,7 +468,7 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() // Broadcast that the pedestrian data is ready to spawn OnPedestrianDataReadyToSpawn.Broadcast(); - + // At this point data should be ready to spawn SpawnMaxPedestrians(ArchetypeSharedFragmentValues); } @@ -468,5 +499,5 @@ void UMassEntitySpawnSubsystem::BuildPedestrianRepresentationFragmentData() // Add the shared fragment to the build context PedestrianTemplateData.AddSharedFragment(NiagaraSharedDataFrag); } - + } diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h index 9cecb2c44..cfa7c47b3 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h @@ -158,10 +158,18 @@ class PROJECTMOBIUS_API UAgentDataSubsystem : public UTickableWorldSubsystem, pu public: /** Pointer to the FRunnable JSON Parser */ TUniquePtr JsonDataRunnable; - + /** JSON Object */ TSharedPtr JSONObject; + /** + * HDF5 entity metadata cached before the runnable is torn down. + * Populated in BuildPedestrianMovementFragmentData() so that + * PedestrianInitializeMOP can access entity info after AgentDataRunnableCleanup + * has already destroyed JsonDataRunnable. + */ + TArray CachedHdf5Entities; + /** Delegate to broadcast when the simulation data has finished loading */ UPROPERTY() FOnLoadSimulationDataComplete OnLoadSimulationDataComplete; diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MassEntitySpawnSubsystem.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MassEntitySpawnSubsystem.h index cdd8c3851..fe28da942 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MassEntitySpawnSubsystem.h +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MassEntitySpawnSubsystem.h @@ -161,4 +161,7 @@ class PROJECTMOBIUS_API UMassEntitySpawnSubsystem : public UMassSpawnerSubsystem private: /** Cached shared simulation fragment for plot access. */ FSharedStruct SharedSimulationFragment; + + /** Template ID registered with TemplateRegistryInstance, used to call DestroyTemplate on file switch. */ + FMassEntityTemplateID RegisteredPedestrianTemplateID; }; From 7b14ee7b5a3d92b36a36bba980e2302250eed1fd Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:55:46 +1200 Subject: [PATCH 02/12] Add memory tracing, tests and unify entity data Introduce a lightweight memory-tracing helper (MemoryTraceHelper.h) and instrument key places (AgentDataSubsystem, MassEntitySpawnSubsystem, runnable lifecycle) with FMobiusMemSnapshot to diagnose allocation/regression issues (compiled out in Shipping). Unify simulation-entity paths by extracting JSON entity rows into Hdf5Data.Entities and renaming CachedHdf5Entities to CachedEntityData; remove legacy JSON-only fallback code and simplify entity parsing paths to avoid dangling JSONObject lifetimes. Add a ProjectMobiusTests module with automated memory tests (simulation fragment lifecycle, reload cycles, and entity cache lifecycle) and register the tests; update .uproject to include the new tests module and add the module Build.cs. Minor logging and cleanup improvements around runnable/switch/gc points to help track memory during loads and teardown. Memory management is better but still need to address it further. Used Claude to generate text memory logger. --- .../ProjectMobius/ProjectMobius.uproject | 5 + .../MassAI/SubSystems/AgentDataSubsystem.cpp | 255 ++++++------------ .../SubSystems/MassEntitySpawnSubsystem.cpp | 78 ++++-- .../MassAI/SubSystems/AgentDataSubsystem.h | 29 +- .../MassAI/SubSystems/MemoryTraceHelper.h | 67 +++++ .../Private/ProjectMobiusTestsModule.cpp | 4 + .../Private/Tests/AgentDataMemoryTest.cpp | 231 ++++++++++++++++ .../ProjectMobiusTests.Build.cs | 25 ++ 8 files changed, 475 insertions(+), 219 deletions(-) create mode 100644 UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h create mode 100644 UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/ProjectMobiusTestsModule.cpp create mode 100644 UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp create mode 100644 UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs diff --git a/UnrealFolder/ProjectMobius/ProjectMobius.uproject b/UnrealFolder/ProjectMobius/ProjectMobius.uproject index 9310350b1..aaf6e946c 100644 --- a/UnrealFolder/ProjectMobius/ProjectMobius.uproject +++ b/UnrealFolder/ProjectMobius/ProjectMobius.uproject @@ -58,6 +58,11 @@ "Name": "MobiusEditor", "Type": "Editor", "LoadingPhase": "Default" + }, + { + "Name": "ProjectMobiusTests", + "Type": "Developer", + "LoadingPhase": "Default" } ], "Plugins": [ diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp index b9769db4f..00b28cc78 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp @@ -42,6 +42,11 @@ #include "MassAI/SubSystems/MassRepresentation/MRS_RepresentationSubsystem.h" #include "Subsystems/LoadingSubsystem.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" +#include "MassAI/SubSystems/MemoryTraceHelper.h" + +#if !UE_BUILD_SHIPPING +DEFINE_LOG_CATEGORY(LogMobiusMemory); +#endif namespace { @@ -186,107 +191,6 @@ void UAgentDataSubsystem::Tick(float DeltaTime) } } -void UAgentDataSubsystem::GetJSONDataFile(FString InJsonDataFile) -{ - if (!CheckFilePathExists(InJsonDataFile)) - { - UE_LOG(LogTemp, Warning, TEXT("File Path does not exist")); - ReportAgentDataError(this, - TEXT("Simulation file not found"), - FString::Printf(TEXT("JSON file does not exist: %s"), *InJsonDataFile), - TEXT("AgentDataSubsystem")); - return; - } - TSharedRef> JSONReader = TJsonReaderFactory::Create(JSONDataString); - - // Create JSON Reader and load String - CreateJsonReaderAndString(JSONDataString, JSONReader, InJsonDataFile); - - // Deserialize JSON Data - bool bDeserializeSuccess = FJsonSerializer::Deserialize(JSONReader, JSONObject); - - if (!bDeserializeSuccess) - { - UE_LOG(LogTemp, Warning, TEXT("Failed to Deserialize JSON Data")); - ReportAgentDataError(this, - TEXT("Failed to parse simulation file"), - FString::Printf(TEXT("Failed to deserialize JSON data from: %s"), *InJsonDataFile), - TEXT("AgentDataSubsystem")); - return; - } -} - -void UAgentDataSubsystem::GetUpdatedJSONDataFile() -{ - // log the file has changed - UE_LOG(LogTemp, Warning, TEXT("Data File Changed")); - - // Get the Game Instance - if(UProjectMobiusGameInstance* GameInst = GetMobiusGameInstance(GetWorld())) - { - // update the data file - JSONDataFile = GameInst->GetPedestrianDataFilePath(); - - // Get the JSON Data File - GetJSONDataFile(JSONDataFile); - - // Check that json object is not still nullptr - if (JSONObject == nullptr) - { - UE_LOG(LogTemp, Warning, TEXT("JSON Object is nullptr")); - ReportAgentDataError(this, - TEXT("Simulation file invalid"), - TEXT("JSON object is null after loading the simulation file."), - TEXT("AgentDataSubsystem")); - } - else - { - //CalculateMaxEntitiesPermitted(); - } - } - else - { - ReportAgentDataError(this, - TEXT("Game instance missing"), - TEXT("Unable to access the game instance while loading simulation data."), - TEXT("AgentDataSubsystem")); - } - -} - -void UAgentDataSubsystem::BuildPedestrianAgentInfo() -{ - if (!JSONObject.IsValid()) - { - ReportAgentDataError(this, - TEXT("Simulation data missing"), - TEXT("No JSON data is loaded for pedestrian info."), - TEXT("AgentDataSubsystem")); - return; - } - TArray> JsonEntityDataArray = JSONObject->GetArrayField(StringCast("entities")); - - // loop through the JSON array - for (int32 entityIndex = 0; entityIndex < JsonEntityDataArray.Num(); entityIndex++) - { - if (!JsonEntityDataArray[entityIndex]->AsObject().IsValid()) - { - UE_LOG(LogTemp, Warning, TEXT("Invalid JSON Object")); - ReportAgentDataError(this, - TEXT("Invalid entity data"), - TEXT("Encountered an invalid entity object while parsing."), - TEXT("AgentDataSubsystem")); - break; - } - - // Get the JSON object for this - TSharedPtr JSONEntityDataObject = JsonEntityDataArray[entityIndex]->AsObject(); - - FEntityInfoFragment EntityInfo; - ParseEntityInfo(JSONEntityDataObject, EntityInfo); - - } -} void UAgentDataSubsystem::SetEntityInfoByIndex(int32 Index, FEntityInfoFragment& EntityInfoFragToUpdate) const { if (Index < 0 || Index >= MaxAgents) @@ -299,19 +203,19 @@ void UAgentDataSubsystem::SetEntityInfoByIndex(int32 Index, FEntityInfoFragment& return; } - // Check if we have HDF5 entity data cached (moved out of runnable before it was torn down) - if (CachedHdf5Entities.Num() > 0) + // Check if we have entity data cached (moved out of runnable before it was torn down) + if (CachedEntityData.Num() > 0) { - if (!CachedHdf5Entities.IsValidIndex(Index)) + if (!CachedEntityData.IsValidIndex(Index)) { ReportAgentDataError(this, TEXT("Entity data missing"), - TEXT("Entity index is not present in the HDF5 data."), + TEXT("Entity index is not present in the simulation data."), TEXT("AgentDataSubsystem")); return; } - const FHdf5EntityData& Entity = CachedHdf5Entities[Index]; + const FHdf5EntityData& Entity = CachedEntityData[Index]; EntityInfoFragToUpdate.EntityID = Entity.Id; EntityInfoFragToUpdate.EntityName = Entity.Name; EntityInfoFragToUpdate.EntitySimTimeS = FString::SanitizeFloat(Entity.SimTimeS); @@ -321,30 +225,10 @@ void UAgentDataSubsystem::SetEntityInfoByIndex(int32 Index, FEntityInfoFragment& return; } - // Fall back to JSON data - if (!JSONObject.IsValid()) - { - ReportAgentDataError(this, - TEXT("Simulation data missing"), - TEXT("No simulation data is loaded for entity info."), - TEXT("AgentDataSubsystem")); - return; - } - - TArray> JsonEntityDataArray = JSONObject->GetArrayField(StringCast("entities", 8)); - if (!JsonEntityDataArray.IsValidIndex(Index)) - { - ReportAgentDataError(this, - TEXT("Entity data missing"), - TEXT("Entity index is not present in the JSON data."), - TEXT("AgentDataSubsystem")); - return; - } - - // Get the JSON object for this - TSharedPtr JSONEntityDataObject = JsonEntityDataArray[Index]->AsObject(); - - ParseEntityInfo(JSONEntityDataObject, EntityInfoFragToUpdate); + ReportAgentDataError(this, + TEXT("Simulation data missing"), + TEXT("No simulation data is loaded for entity info."), + TEXT("AgentDataSubsystem")); } void UAgentDataSubsystem::SetEntityRenderingByIndex(int32 Index, FEntityRenderingFragment& EntityRenderingFragToUpdate) const @@ -363,55 +247,27 @@ void UAgentDataSubsystem::SetEntityRenderingByIndex(int32 Index, FString AgentName; - // Check if we have HDF5 entity data cached (moved out of runnable before it was torn down) - if (CachedHdf5Entities.Num() > 0) + // Check if we have entity data cached (moved out of runnable before it was torn down) + if (CachedEntityData.Num() > 0) { - if (!CachedHdf5Entities.IsValidIndex(Index)) + if (!CachedEntityData.IsValidIndex(Index)) { ReportAgentDataError(this, TEXT("Entity data missing"), - TEXT("Entity index is not present in the HDF5 data."), + TEXT("Entity index is not present in the simulation data."), TEXT("AgentDataSubsystem")); return; } - AgentName = CachedHdf5Entities[Index].Name; + AgentName = CachedEntityData[Index].Name; } else { - // Fall back to JSON data - if (!JSONObject.IsValid()) - { - ReportAgentDataError(this, - TEXT("Simulation data missing"), - TEXT("No simulation data is loaded for entity rendering."), - TEXT("AgentDataSubsystem")); - return; - } - - TArray> JsonEntityDataArray = JSONObject->GetArrayField(StringCast("entities", 8)); - if (!JsonEntityDataArray.IsValidIndex(Index)) - { - ReportAgentDataError(this, - TEXT("Entity data missing"), - TEXT("Entity index is not present in the JSON data."), - TEXT("AgentDataSubsystem")); - return; - } - - // Get the JSON object for this - TSharedPtr JSONEntityDataObject = JsonEntityDataArray[Index]->AsObject(); - - if (!JSONEntityDataObject.IsValid()) - { - UE_LOG(LogTemp, Warning, TEXT("Invalid JSON Object")); - ReportAgentDataError(this, - TEXT("Invalid entity data"), - TEXT("Entity object is invalid while parsing rendering data."), - TEXT("AgentDataSubsystem")); - return; - } - AgentName = JSONEntityDataObject->GetStringField(StringCast("name", 4)); + ReportAgentDataError(this, + TEXT("Simulation data missing"), + TEXT("No simulation data is loaded for entity rendering."), + TEXT("AgentDataSubsystem")); + return; } // update gender @@ -797,6 +653,27 @@ void FProcessSimulationDataRunnable::ReadJSONMetadataValues(bool& bCalculateTime // Set the entity count from count of entities in the JSON object array if the metadata fields are not present or blank MaxAgents = JSONObject->GetArrayField(StringCast("entities")).Num(); } + + // Extract entities to Hdf5Data.Entities to unify data paths and allow JSONObject to be deleted + if (JSONObject->HasField(StringCast("entities"))) + { + const TArray>& JsonEntityDataArray = JSONObject->GetArrayField(StringCast("entities")); + Hdf5Data.Entities.SetNum(JsonEntityDataArray.Num()); + for (int32 i = 0; i < JsonEntityDataArray.Num(); ++i) + { + if (!JsonEntityDataArray[i].IsValid() || !JsonEntityDataArray[i]->AsObject().IsValid()) continue; + TSharedPtr JSONEntityDataObject = JsonEntityDataArray[i]->AsObject(); + FHdf5EntityData& Entity = Hdf5Data.Entities[i]; + Entity.Id = JSONEntityDataObject->GetIntegerField(StringCast("id")); + Entity.Name = JSONEntityDataObject->GetStringField(StringCast("name")); + // JSON string to float conversion for SimTimeS + FString SimTimeString = JSONEntityDataObject->GetStringField(StringCast("simTimeS")); + Entity.SimTimeS = FCString::Atof(*SimTimeString); + Entity.MaxSpeed = JSONEntityDataObject->GetNumberField(StringCast("max_speed")); + Entity.MPlane = JSONEntityDataObject->GetStringField(StringCast("m_plane")); + Entity.Map = JSONEntityDataObject->GetIntegerField(StringCast("map")); + } + } } void FProcessSimulationDataRunnable::ReadHDF5MetadataValues(bool& bCalculateTimeBetweenSteps, bool& bCalculateMaxTime) @@ -1664,12 +1541,18 @@ void FProcessSimulationDataRunnable::FinalizeProgress() uint32 FProcessSimulationDataRunnable:: Run() { bIsRunning = true; + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapRunStart = FMobiusMemSnapshot::Take(TEXT("Run_Start")); + SnapRunStart.LogAbsolute(); +#endif + UAgentDataSubsystem* Subsys = OwnerSubsystem.Get(); //TODO: this may need to be a weak ptr check/and/or variable // Broadcast the current percentage of the data loaded if (Subsys) { Subsys->ProgressQueue.Enqueue(0.0f); - + // First loading task Subsys->LoadingTaskQueue.Enqueue(TEXT("Loading Simulation Data From File...")); } @@ -1681,6 +1564,10 @@ uint32 FProcessSimulationDataRunnable:: Run() return 0; } +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Run_AfterDeserialize")).LogDelta(SnapRunStart); +#endif + bool bCalculateTimeBetweenSteps = true; bool bCalculateMaxTime = true; @@ -1716,6 +1603,10 @@ uint32 FProcessSimulationDataRunnable:: Run() // Run the main simulation loop RunSimulationDataGatheringLoop(bCalculateTimeBetweenSteps, bCalculateMaxTime); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Run_AfterGatherLoop")).LogDelta(SnapRunStart); +#endif + if (bShouldStop) { return 0; @@ -1755,16 +1646,31 @@ uint32 FProcessSimulationDataRunnable:: Run() // the local SamplesByTimestep (which held raw pointers into Samples) is out of scope. // Hdf5Data.Entities must NOT be freed here — PedestrianInitializeMOP accesses it on the // game thread after BatchCreateEntities, before BuildPedestrianMovementFragmentData moves - // it into AgentDataSubsystem::CachedHdf5Entities. + // it into AgentDataSubsystem::CachedEntityData. if (SimulationFileType == ESimulationFileType::ESFT_HDF5) { Hdf5Data.Samples.Empty(); Hdf5Data.Samples.Shrink(); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Run_AfterSamplesFree")).LogDelta(SnapRunStart); +#endif + } + else if (SimulationFileType == ESimulationFileType::ESFT_JSON) + { + JSONObject.Reset(); + SimulationDataFile.Empty(); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Run_AfterSamplesFree")).LogDelta(SnapRunStart); +#endif } // Send the final progress and completion events FinalizeProgress(); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Run_FinalizeComplete")).LogDelta(SnapRunStart); +#endif + bIsRunning = false; return 0; // return 0 to indicate that the thread has ended } @@ -1774,6 +1680,11 @@ void FProcessSimulationDataRunnable::Stop() } void FProcessSimulationDataRunnable::Exit() { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapExitStart = FMobiusMemSnapshot::Take(TEXT("Exit_Start")); + SnapExitStart.LogAbsolute(); +#endif + // as the runnable contains multiple properties that are not handled by garbage collection, // we need to ensure that we clean up properly @@ -1816,6 +1727,10 @@ void FProcessSimulationDataRunnable::Exit() NumOfAgentsPerTimeStep.Shrink(); bReadyToDelete = true; // Set the flag to true to indicate that the runnable is ready to be deleted + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Exit_Complete")).LogDelta(SnapExitStart); +#endif } TArray FProcessSimulationDataRunnable::GetMovementSamples(int32 AgentID) diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index fe3035061..34a5cdc20 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -50,6 +50,7 @@ #include "MassAI/Fragments/EntityTags/PedestrianCollisionTags.h" #include "MassAI/Fragments/SharedFragments/RepresentationFragments/AgentNiagaraDataFrag.h" #include "MassAI/SubSystems/PedestrianSignalSubsystem.h" +#include "MassAI/SubSystems/MemoryTraceHelper.h" #include "Subsystems/StatisticSubsystem.h" class UTimeDilationSubSystem; @@ -209,6 +210,11 @@ void UMassEntitySpawnSubsystem::AgentDataRunnableCleanup(TUniquePtrStop(); @@ -224,6 +230,10 @@ void UMassEntitySpawnSubsystem::AgentDataRunnableCleanup(TUniquePtr()) + { + for (auto& Pair : Frag->SimulationData) + { + Pair.Value.Empty(); + Pair.Value.Shrink(); + } + Frag->SimulationData.Empty(); + Frag->SimulationData.Shrink(); + } SharedSimulationFragment = FSharedStruct(); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSharedFragReset")).LogDelta(SnapSwitchStart); +#endif + LoadPedestrianData(); } @@ -372,6 +408,11 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() { UE_LOG(LogTemp, Warning, TEXT("Building Pedestrian Movement Fragment Data")); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapBuildStart = FMobiusMemSnapshot::Take(TEXT("BuildFrag_Start")); + SnapBuildStart.LogAbsolute(); +#endif + // get the mobius widget subsystem auto LoadingSubsystem = GetWorld()->GetSubsystem(); @@ -387,29 +428,20 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() } FSimulationFragment SimulationFragment; - TSharedPtr JSONObjectLocal; float TimeBetweenStepsLocal = 0.f; ESimulationFileType LoadedFileType = ESimulationFileType::ESFT_Unknown; if (AgentDataSubsystem->JsonDataRunnable) { - SimulationFragment = MoveTemp(AgentDataSubsystem->JsonDataRunnable->AgentMovementInfoData); - JSONObjectLocal = MoveTemp(AgentDataSubsystem->JsonDataRunnable->JSONObject); + SimulationFragment = MoveTemp(AgentDataSubsystem->JsonDataRunnable->AgentMovementInfoData); TimeBetweenStepsLocal = AgentDataSubsystem->JsonDataRunnable->TimeBetweenSteps; - LoadedFileType = AgentDataSubsystem->JsonDataRunnable->SimulationFileType; + LoadedFileType = AgentDataSubsystem->JsonDataRunnable->SimulationFileType; + NumOfAgentsPerTimeStep = AgentDataSubsystem->JsonDataRunnable->NumOfAgentsPerTimeStep; - // Cache HDF5 entity metadata before the runnable is torn down. + // Cache entity metadata before the runnable is torn down. // PedestrianInitializeMOP fires after SpawnMaxPedestrians destroys the runnable, // so SetEntityInfoByIndex / SetEntityRenderingByIndex must read from here instead. - if (LoadedFileType == ESimulationFileType::ESFT_HDF5) - { - AgentDataSubsystem->CachedHdf5Entities = MoveTemp(AgentDataSubsystem->JsonDataRunnable->Hdf5Data.Entities); - } - else - { - AgentDataSubsystem->CachedHdf5Entities.Empty(); - AgentDataSubsystem->CachedHdf5Entities.Shrink(); - } + AgentDataSubsystem->CachedEntityData = MoveTemp(AgentDataSubsystem->JsonDataRunnable->Hdf5Data.Entities); } //UE_LOG(LogTemp, Warning, TEXT("Building Pedestrian Movement Fragment Data")); @@ -421,24 +453,12 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() // Add the tag to prevent collision updates PedestrianTemplateData.AddTag(); - NumOfAgentsPerTimeStep = AgentDataSubsystem->JsonDataRunnable->NumOfAgentsPerTimeStep; - if (NumOfAgentsPerTimeStep.IsValidIndex(0)) { // log i 0 for the number of agents per time step UE_LOG(LogTemp, Warning, TEXT("Number of Agents Per Time Step: %d"), NumOfAgentsPerTimeStep[0]); } - // Set the json object on the agent data subsystem only if we loaded a JSON file - if (LoadedFileType == ESimulationFileType::ESFT_JSON) - { - AgentDataSubsystem->JSONObject = JSONObjectLocal; - } - else - { - AgentDataSubsystem->JSONObject.Reset(); - } - // Get Time Dilation from the ProjectMobius Game Instance UTimeDilationSubSystem* TimeDilationSubSystem = GetWorld()->GetSubsystem(); @@ -470,7 +490,13 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() OnPedestrianDataReadyToSpawn.Broadcast(); // At this point data should be ready to spawn +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("BuildFrag_PreSpawn")).LogDelta(SnapBuildStart); +#endif SpawnMaxPedestrians(ArchetypeSharedFragmentValues); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("BuildFrag_AfterSpawn")).LogDelta(SnapBuildStart); +#endif } const FSimulationFragment* UMassEntitySpawnSubsystem::GetSimulationFragment() const diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h index cfa7c47b3..316f276c8 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h @@ -85,27 +85,13 @@ class PROJECTMOBIUS_API UAgentDataSubsystem : public UTickableWorldSubsystem, pu virtual void Deinitialize() override; virtual void Tick(float DeltaTime) override; - - virtual TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(UAgentDataSubsystem, STATGROUP_Tickables); } - - /** Get JSON Data File */ - UFUNCTION(BlueprintCallable, Category = "MassAI|Data") - void GetJSONDataFile(FString InJsonDataFile); +virtual TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(UAgentDataSubsystem, STATGROUP_Tickables); } /** - * Gets Json data when file has been changed + * Helper used to parse entity info fields from a JSON object into an EntityInfoFragment. + * Called by PedestrianInitializeMOP when the JSON path is active. */ - UFUNCTION() - void GetUpdatedJSONDataFile(); - - /** Build Pedestrian Agent Info */ - UFUNCTION(BlueprintCallable, Category = "MassAI|Data") - void BuildPedestrianAgentInfo(); - - /** - * Helper used to parse entity info fields from a JSON object - */ - static void ParseEntityInfo(const TSharedPtr& JsonObject, FEntityInfoFragment& OutInfo); + static void ParseEntityInfo(const TSharedPtr& JsonObject, FEntityInfoFragment& OutInfo); /** * Set the Entity Info fragment by Index from the JSON data @@ -159,16 +145,13 @@ class PROJECTMOBIUS_API UAgentDataSubsystem : public UTickableWorldSubsystem, pu /** Pointer to the FRunnable JSON Parser */ TUniquePtr JsonDataRunnable; - /** JSON Object */ - TSharedPtr JSONObject; - /** - * HDF5 entity metadata cached before the runnable is torn down. + * Entity metadata cached before the runnable is torn down. * Populated in BuildPedestrianMovementFragmentData() so that * PedestrianInitializeMOP can access entity info after AgentDataRunnableCleanup * has already destroyed JsonDataRunnable. */ - TArray CachedHdf5Entities; + TArray CachedEntityData; /** Delegate to broadcast when the simulation data has finished loading */ UPROPERTY() diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h new file mode 100644 index 000000000..eb771d4f1 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h @@ -0,0 +1,67 @@ +/** + * MIT License + * Copyright (c) 2025 ProjectMobius contributors + * + * Lightweight memory-snapshot helper for diagnosing allocation regressions. + * The entire API is compiled out in Shipping builds — zero runtime overhead + * in production. + * + * Usage: + * #if !UE_BUILD_SHIPPING + * FMobiusMemSnapshot Before = FMobiusMemSnapshot::Take(TEXT("BeforeLoad")); + * // ... work ... + * FMobiusMemSnapshot::Take(TEXT("AfterLoad")).LogDelta(Before); + * #endif + */ +#pragma once + +#if !UE_BUILD_SHIPPING + +#include "CoreMinimal.h" +#include "HAL/PlatformMemory.h" +#include "HAL/PlatformTime.h" +#include "Logging/LogMacros.h" + +PROJECTMOBIUS_API DECLARE_LOG_CATEGORY_EXTERN(LogMobiusMemory, Log, All); + +struct FMobiusMemSnapshot +{ + FString Label; + uint64 UsedPhysical = 0; + uint64 UsedVirtual = 0; + double TimestampSec = 0.0; + + /** Capture a memory snapshot right now. */ + static FMobiusMemSnapshot Take(const FString& InLabel) + { + FPlatformMemoryStats Stats = FPlatformMemory::GetStats(); + FMobiusMemSnapshot S; + S.Label = InLabel; + S.UsedPhysical = Stats.UsedPhysical; + S.UsedVirtual = Stats.UsedVirtual; + S.TimestampSec = FPlatformTime::Seconds(); + return S; + } + + /** Log this snapshot's values relative to a previous one. */ + void LogDelta(const FMobiusMemSnapshot& Previous) const + { + const int64 DeltaPhysMB = (static_cast(UsedPhysical) - static_cast(Previous.UsedPhysical)) / (1024 * 1024); + const int64 DeltaVirtMB = (static_cast(UsedVirtual) - static_cast(Previous.UsedVirtual)) / (1024 * 1024); + const double DeltaSec = TimestampSec - Previous.TimestampSec; + + UE_LOG(LogMobiusMemory, Warning, + TEXT("MEM [%s] Phys=%+lldMB Virt=%+lldMB (%.3fs since [%s])"), + *Label, DeltaPhysMB, DeltaVirtMB, DeltaSec, *Previous.Label); + } + + /** Log this snapshot's absolute values (useful for the very first snapshot). */ + void LogAbsolute() const + { + UE_LOG(LogMobiusMemory, Warning, + TEXT("MEM [%s] Phys=%llumb Virt=%llumb"), + *Label, UsedPhysical / (1024 * 1024), UsedVirtual / (1024 * 1024)); + } +}; + +#endif // !UE_BUILD_SHIPPING diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/ProjectMobiusTestsModule.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/ProjectMobiusTestsModule.cpp new file mode 100644 index 000000000..2ede0b948 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/ProjectMobiusTestsModule.cpp @@ -0,0 +1,4 @@ +// Copyright (c) 2025 ProjectMobius contributors. Licensed under MIT. +#include "Modules/ModuleManager.h" + +IMPLEMENT_MODULE(FDefaultModuleImpl, ProjectMobiusTests) diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp new file mode 100644 index 000000000..3784b1f56 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp @@ -0,0 +1,231 @@ +// Copyright (c) 2025 ProjectMobius contributors. Licensed under MIT. +// +// AgentDataMemoryTest.cpp +// +// Automated tests that exercise the simulation data lifecycle and assert that +// memory returns to baseline after teardown. These do NOT require a UWorld — +// they drive the data structures directly. +// +// Run from the Session Frontend (search "ProjectMobius.Memory") or: +// UnrealEditor ProjectMobius.uproject +// -ExecCmds="Automation RunTests ProjectMobius.Memory" -log +// +#if !UE_BUILD_SHIPPING + +#include "CoreMinimal.h" +#include "Misc/AutomationTest.h" +#include "HAL/PlatformMemory.h" +#include "MassAI/SubSystems/MemoryTraceHelper.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static uint64 GetUsedPhysicalMB() +{ + return FPlatformMemory::GetStats().UsedPhysical / (1024 * 1024); +} + +/** Tolerance in MB below which a memory delta is considered acceptable. */ +static constexpr int64 kLeakToleranceMB = 10; + +// --------------------------------------------------------------------------- +// Test 1: Simulation data map load/teardown +// +// Allocates a TMap> mirroring the layout of +// FSimulationFragment::SimulationData, then lets it go out of scope. +// Asserts that used physical memory returns within kLeakToleranceMB of +// the baseline. +// --------------------------------------------------------------------------- +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FSimulationFragmentLifecycleTest, + "ProjectMobius.Memory.SimulationFragment.LoadAndTeardown", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) + +bool FSimulationFragmentLifecycleTest::RunTest(const FString& Parameters) +{ + // --- Baseline --- + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + const int64 BaselineMB = static_cast(GetUsedPhysicalMB()); + UE_LOG(LogMobiusMemory, Warning, TEXT("[SimFragLifecycle] Baseline: %lldMB"), BaselineMB); + + // --- Allocate synthetic simulation data --- + // 500 agents x 200 timesteps (moderate scale, keeps test fast) + // Uses TMap> which mirrors FSimulationFragment::SimulationData + // without requiring a dependency on SimulationFragment.h (and its transitive MobiusCore dep). + { + TMap> SimulationData; + const int32 NumAgents = 500; + const int32 NumTimesteps = 200; + + for (int32 AgentID = 0; AgentID < NumAgents; ++AgentID) + { + TArray Samples; + Samples.SetNum(NumTimesteps); + for (int32 T = 0; T < NumTimesteps; ++T) + { + Samples[T] = FVector(static_cast(AgentID), static_cast(T), 0.f); + } + SimulationData.Add(AgentID, MoveTemp(Samples)); + } + + const int64 AfterAllocMB = static_cast(GetUsedPhysicalMB()); + UE_LOG(LogMobiusMemory, Warning, + TEXT("[SimFragLifecycle] AfterAlloc: %lldMB (delta=%+lldMB)"), + AfterAllocMB, AfterAllocMB - BaselineMB); + + // SimulationData goes out of scope here -> destructor frees memory + } + + // --- Teardown: force GC and measure --- + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + const int64 AfterTeardownMB = static_cast(GetUsedPhysicalMB()); + const int64 DeltaMB = AfterTeardownMB - BaselineMB; + + UE_LOG(LogMobiusMemory, Warning, + TEXT("[SimFragLifecycle] AfterTeardown: %lldMB (delta from baseline=%+lldMB, tolerance=%+lldMB)"), + AfterTeardownMB, DeltaMB, kLeakToleranceMB); + + TestTrue( + FString::Printf(TEXT("Memory delta %+lldMB should be within tolerance %+lldMB"), DeltaMB, kLeakToleranceMB), + DeltaMB <= kLeakToleranceMB); + + return true; +} + +// --------------------------------------------------------------------------- +// Test 2: 3-cycle reload -- asserts no monotonic memory growth +// +// Runs the allocate -> teardown cycle three times and checks that +// post-teardown memory does not grow across cycles. This catches leaks +// that only appear on the second or later reload. +// --------------------------------------------------------------------------- +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FSimulationFragmentReloadCycleTest, + "ProjectMobius.Memory.SimulationFragment.ReloadCycle", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) + +bool FSimulationFragmentReloadCycleTest::RunTest(const FString& Parameters) +{ + static constexpr int32 NumCycles = 3; + static constexpr int32 NumAgents = 500; + static constexpr int32 NumSteps = 200; + + // Allow slightly more headroom for GC jitter across cycles + static constexpr int64 CycleLeakToleranceMB = 15; + + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + const int64 BaselineMB = static_cast(GetUsedPhysicalMB()); + UE_LOG(LogMobiusMemory, Warning, + TEXT("[ReloadCycle] Baseline: %lldMB"), BaselineMB); + + int64 PostTeardownMB[NumCycles] = {}; + + for (int32 Cycle = 0; Cycle < NumCycles; ++Cycle) + { + { + TMap> SimulationData; + for (int32 A = 0; A < NumAgents; ++A) + { + TArray Samples; + Samples.SetNum(NumSteps); + for (int32 T = 0; T < NumSteps; ++T) + { + Samples[T] = FVector(static_cast(A + Cycle * 1000), static_cast(T), 0.f); + } + SimulationData.Add(A, MoveTemp(Samples)); + } + // SimulationData freed at end of scope + } + + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + PostTeardownMB[Cycle] = static_cast(GetUsedPhysicalMB()); + + UE_LOG(LogMobiusMemory, Warning, + TEXT("[ReloadCycle] Cycle %d post-teardown: %lldMB (delta from baseline=%+lldMB)"), + Cycle + 1, PostTeardownMB[Cycle], PostTeardownMB[Cycle] - BaselineMB); + } + + // Assert: cycle 3 post-teardown is not significantly above cycle 1 post-teardown + const int64 GrowthMB = PostTeardownMB[NumCycles - 1] - PostTeardownMB[0]; + UE_LOG(LogMobiusMemory, Warning, + TEXT("[ReloadCycle] Growth across %d cycles: %+lldMB (tolerance %+lldMB)"), + NumCycles, GrowthMB, CycleLeakToleranceMB); + + TestTrue( + FString::Printf( + TEXT("Memory growth across %d reload cycles (%+lldMB) should be <= %lldMB"), + NumCycles, GrowthMB, CycleLeakToleranceMB), + GrowthMB <= CycleLeakToleranceMB); + + return true; +} + +// --------------------------------------------------------------------------- +// Test 3: Entity cache lifecycle +// +// Exercises the CachedEntityData array pattern used in AgentDataSubsystem +// (move-in on HDF5 load, Empty+Shrink on JSON load) and checks for leaks. +// Uses a self-contained struct so this module does not depend on Hdf5DataPlugin +// headers that may have their own transitive includes. +// --------------------------------------------------------------------------- + +struct FTestEntityData +{ + int32 Id = 0; + FString Name; + float MaxSpeed = 0.f; +}; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHdf5EntityCacheLifecycleTest, + "ProjectMobius.Memory.HDF5.EntityCacheLifecycle", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) + +bool FHdf5EntityCacheLifecycleTest::RunTest(const FString& Parameters) +{ + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + const int64 BaselineMB = static_cast(GetUsedPhysicalMB()); + + static constexpr int32 NumEntities = 5000; + + TArray CachedEntities; + + // Simulate HDF5 load -- fill the cache + { + TArray SourceEntities; + SourceEntities.SetNum(NumEntities); + for (int32 i = 0; i < NumEntities; ++i) + { + SourceEntities[i].Id = i; + SourceEntities[i].Name = FString::Printf(TEXT("Agent_%d"), i); + SourceEntities[i].MaxSpeed = 1.5f; + } + CachedEntities = MoveTemp(SourceEntities); + } + + const int64 AfterHdf5LoadMB = static_cast(GetUsedPhysicalMB()); + UE_LOG(LogMobiusMemory, Warning, + TEXT("[HDF5Cache] AfterHDF5Load: %lldMB (delta=%+lldMB)"), + AfterHdf5LoadMB, AfterHdf5LoadMB - BaselineMB); + + // Simulate JSON load -- clear the cache (mirrors the else-branch in BuildPedestrianMovementFragmentData) + CachedEntities.Empty(); + CachedEntities.Shrink(); + + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + const int64 AfterJsonSwitchMB = static_cast(GetUsedPhysicalMB()); + const int64 DeltaMB = AfterJsonSwitchMB - BaselineMB; + + UE_LOG(LogMobiusMemory, Warning, + TEXT("[HDF5Cache] AfterJSONSwitch: %lldMB (delta from baseline=%+lldMB, tolerance=%+lldMB)"), + AfterJsonSwitchMB, DeltaMB, kLeakToleranceMB); + + TestTrue( + FString::Printf(TEXT("HDF5 entity cache delta %+lldMB should be within tolerance %+lldMB"), DeltaMB, kLeakToleranceMB), + DeltaMB <= kLeakToleranceMB); + + return true; +} + +#endif // !UE_BUILD_SHIPPING diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs new file mode 100644 index 000000000..5bd2eef17 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2025 ProjectMobius contributors. Licensed under MIT. +using UnrealBuildTool; + +public class ProjectMobiusTests : ModuleRules +{ + public ProjectMobiusTests(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PrivateDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + "ProjectMobius", + "Hdf5DataPlugin", + "MassEntity", + "StructUtils", + "MassCommon", + "MassSpawner", + "Json", + "JsonUtilities", + }); + } +} From e509902cf1c7101c33002b8b84cc2a24576bf6b8 Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:11:28 +1200 Subject: [PATCH 03/12] Finish SimulationData TSharedPtr move and harden FlowCounter nulls Wraps up the FSimulationFragment heap indirection started in the previous commit. SimulationData is now a TSharedPtr so the ~4 GB block can be freed on file switch without waiting for the Mass archetype that permanently holds the fragment struct. Updated every call site that poked the member directly - PedestrianInitializeMOP, PedestrianMovementProcessor, AgentDataSubsystem (JSON + HDF5 paths), MassEntitySpawnSubsystem, FloorStatsWidget. MassEntitySpawnSubsystem now runs a second GC pass after clearing the template, and the processing runnable ends with FMemory::Trim() so pages actually return to the OS. Added a few FMobiusMemSnapshot probes around the Juelich convert and flow-counter reset for delta tracking. Used Claude (AI) to re-audit FlowCounter.cpp / StatisticSubsystem.cpp after the earlier HasAgentBeenCountedInFlowCounter nullptr fix to identify any other potential issues of the same shape. It turned up several - IsAgentLocationInAFlowCounterBand, SendDataToFlowCounter and SendArrayDataToFlowCounter were all missing the IsActorBeingDestroyed guard; the FlashBarrierColor timer captured raw this and was never cleared in EndPlay (now WeakObjectPtr + EndPlay cleanup); and GetHoveredAgentInfoMeshData was quietly MoveTemp-ing its backing member, so a second read per update returned default-constructed data (now a plain copy, matching the Selected equivalent). Added null guards on the pillar meshes and trigger box, and tagged the two GT-only reader paths with comments so a future non-GT caller will trip a visible reminder instead of a race. Deleted dead code the audit surfaced: unused StatisticSubsystem TObjectPtr on AFlowCounter, ThreadSafeNewAgentDataQueue + its FBuckectTempData struct, a stale BucketData copy inside a hot loop, and a block of commented-out AsyncTask scaffolding in SendArrayDataToFlowCounter. Project config: ProjectMobiusTests moved to Editor type with AutomationController added so automation macros load in editor builds. DefaultGame.ini flipped CrashReporter on and ForDistribution off for dev iteration. RightBracket added as an extra console key. --- .../ProjectMobius/Config/DefaultGame.ini | 4 +- .../ProjectMobius/Config/DefaultInput.ini | 1 + .../ProjectMobius/ProjectMobius.uproject | 2 +- .../MobiusCore/Private/Actors/FlowCounter.cpp | 49 ++++---- .../Private/Subsystems/StatisticSubsystem.cpp | 69 +++-------- .../MobiusCore/Public/Actors/FlowCounter.h | 18 --- .../UI/Components/FloorStatsWidget.cpp | 11 +- .../PedestrianInitializeMOP.cpp | 8 +- .../PedestrianMovementProcessor.cpp | 17 +-- .../MassAI/SubSystems/AgentDataSubsystem.cpp | 117 ++++++++++-------- .../SubSystems/MassEntitySpawnSubsystem.cpp | 23 ++-- .../SharedFragments/SimulationFragment.h | 7 +- .../Source/ProjectMobiusEditor.Target.cs | 2 +- .../ProjectMobiusTests.Build.cs | 1 + 14 files changed, 154 insertions(+), 175 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Config/DefaultGame.ini b/UnrealFolder/ProjectMobius/Config/DefaultGame.ini index fa5a39982..ed82f9491 100644 --- a/UnrealFolder/ProjectMobius/Config/DefaultGame.ini +++ b/UnrealFolder/ProjectMobius/Config/DefaultGame.ini @@ -20,7 +20,7 @@ Build=IfProjectHasCode BuildConfiguration=PPBC_DebugGame BuildTarget=ProjectMobius FullRebuild=True -ForDistribution=True +ForDistribution=False IncludeDebugFiles=True BlueprintNativizationMethod=Disabled bIncludeNativizedAssetsInProjectGeneration=False @@ -57,7 +57,7 @@ bShareMaterialShaderCode=True bDeterministicShaderCodeOrder=False bSharedMaterialNativeLibraries=True ApplocalPrerequisitesDirectory=(Path="") -IncludeCrashReporter=False +IncludeCrashReporter=True InternationalizationPreset=English -CulturesToStage=en +CulturesToStage=en diff --git a/UnrealFolder/ProjectMobius/Config/DefaultInput.ini b/UnrealFolder/ProjectMobius/Config/DefaultInput.ini index f432165ee..ad29d2f3d 100644 --- a/UnrealFolder/ProjectMobius/Config/DefaultInput.ini +++ b/UnrealFolder/ProjectMobius/Config/DefaultInput.ini @@ -166,6 +166,7 @@ DefaultTouchInterface=/Engine/MobileResources/HUD/DefaultVirtualJoysticks.Defaul -ConsoleKeys=Tilde +ConsoleKeys=Tilde +ConsoleKeys=Caret ++ConsoleKeys=RightBracket [/Script/EnhancedInput.EnhancedInputDeveloperSettings] +DefaultMappingContexts=(InputMappingContext="/Game/00_UserAndInputs/InputMappings/VR_Inputs/IMC_Default.IMC_Default",Priority=0,bAddImmediately=True,bRegisterWithUserSettings=False) diff --git a/UnrealFolder/ProjectMobius/ProjectMobius.uproject b/UnrealFolder/ProjectMobius/ProjectMobius.uproject index aaf6e946c..4aa0068cd 100644 --- a/UnrealFolder/ProjectMobius/ProjectMobius.uproject +++ b/UnrealFolder/ProjectMobius/ProjectMobius.uproject @@ -61,7 +61,7 @@ }, { "Name": "ProjectMobiusTests", - "Type": "Developer", + "Type": "Editor", "LoadingPhase": "Default" } ], diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp index 80cc503cf..f87f9e881 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp @@ -7,7 +7,6 @@ #include "Components/DeformableQuadComponent.h" #include "Kismet/GameplayStatics.h" #include "Subsystems/StatisticActorManagementSubsystem.h" -#include "Subsystems/StatisticSubsystem.h" #include "Subsystems/TimeDilationSubSystem.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" @@ -453,6 +452,9 @@ void AFlowCounter::EndPlay(const EEndPlayReason::Type EndPlayReason) { Time->OnNewCurrentTime.RemoveDynamic(this, &AFlowCounter::NewSimTime); } + + // Cancel pending colour-reset timer so it can't fire on a destroyed actor + World->GetTimerManager().ClearTimer(FlowColorResetHandle); } // 2) Drain any internal queues so Tick (or anyone) won’t commit after teardown @@ -476,13 +478,6 @@ void AFlowCounter::Tick(float DeltaTime) { Super::Tick(DeltaTime); - // FBuckectTempData BucketData; - // // Dequeue any bucket data - // while (ThreadSafeNewAgentDataQueue.Dequeue(BucketData)) - // { - // AssignAgentToBucketUsingThreshold(BucketData.AgentID, BucketData.IntersectionThreshold); - // } - if (bIsFlowCounterActive) { FFlowCrossingResult R; @@ -610,9 +605,11 @@ void AFlowCounter::UpdateFlowCounterTriggerBoxLocation(const FVector& NewLocatio void AFlowCounter::UpdateFlowCounterTriggerBox() { - if (FlowCounterTriggerBox == nullptr) + if (FlowCounterTriggerBox == nullptr || + FlowCounterPillarMesh1 == nullptr || + FlowCounterPillarMesh2 == nullptr) { - return;// Early exit if the trigger box is not valid + return;// Early exit if any required component is missing } // Update the FlowCounterLineStartLocation and FlowCounterLineEndLocation based on the pillar locations FlowCounterLineStartLocation = FlowCounterPillarMesh1->GetComponentLocation(); @@ -687,9 +684,9 @@ void AFlowCounter::UpdateFlowCounterTriggerBox() bool AFlowCounter::ProcessAgentFlowCrossing(const FFlowCounterData& Data) { if (bTearingDown.Load()) return false; - TWeakObjectPtr WeakThis(this); - AFlowCounter* FlowCounter = WeakThis.Get(); - if (FlowCounter == nullptr) { return false; } + + TWeakObjectPtr WeakThis(this); // captured by AsyncTask lambdas further down + AFlowCounter* FlowCounter = this; if (!FlowCounter->bIsFlowCounterActive) { @@ -701,7 +698,12 @@ bool AFlowCounter::ProcessAgentFlowCrossing(const FFlowCounterData& Data) // We need to store the current sim time when we start processing agents - as this could change while processing float ProcessTime = FlowCounter->CurrentSimTime; - + + if (FlowCounter->FlowCounterTriggerBox == nullptr) + { + return false; + } + // Get the flow counter trigger box so we can check if the agent is within the box UE::Math::TBox FlowCounterBox = FlowCounter->FlowCounterTriggerBox->Bounds.GetBox(); @@ -1047,11 +1049,13 @@ void AFlowCounter::FlashBarrierColor() // schedule revert to BLUE after 0.3s (restart if already running) FTimerManager& TM = GetWorldTimerManager(); TM.ClearTimer(FlowColorResetHandle); - TM.SetTimer(FlowColorResetHandle, [this]() + TWeakObjectPtr WeakSelf(this); + TM.SetTimer(FlowColorResetHandle, [WeakSelf]() { - if (IsValid(CounterBarrierVisualMID)) + AFlowCounter* Self = WeakSelf.Get(); + if (Self && IsValid(Self->CounterBarrierVisualMID)) { - CounterBarrierVisualMID->SetVectorParameterValue(FlowColorParam, FLinearColor::Blue); + Self->CounterBarrierVisualMID->SetVectorParameterValue(Self->FlowColorParam, FLinearColor::Blue); } }, 0.3f, false); } @@ -1066,7 +1070,9 @@ void AFlowCounter::AssignAgentsToBuckets(TArray AllAgents) void AFlowCounter::AssignAgentToBuckets(int32 AgentID) { - // Get the agent's intersection location from the AgentsPassedThroughCounter map + // GT-only: reads AgentsPassedThroughCounter without AgentsMapRW lock. All current + // callers run on the game thread (Tick, NewSimTime, BlueprintCallable). Add locking + // if this ever grows a non-GT caller. FFlowCounterCountedAgentData* AgentData = AgentsPassedThroughCounter.Find(AgentID); // is the data ptr valid ? @@ -1077,9 +1083,7 @@ void AFlowCounter::AssignAgentToBuckets(int32 AgentID) { // we need to check if the agent's intersection location is within the bucket segment or on the start/end point of the segment // TODO: Review this logic with Pete to ensure this is how we should be checking - - FFlowCounterBucketData BucketData = FlowCounterBucketData[i]; - + // if (IsPointOnLineSegment(BucketData.SegmentStart, BucketData.SegmentEnd, AgentData->IntersectionLocation)) // { // // Add the agent to the bucket @@ -1154,7 +1158,8 @@ void AFlowCounter::RemoveAgentFromBuckets(int32 AgentID) void AFlowCounter::UpdateFlowBucketsWithCurrentAgentsFromTimeChange() { - // Get all the Agent IDs from the AgentsPassedThroughCounter map + // GT-only: reads AgentsPassedThroughCounter without AgentsMapRW lock. Called from + // NewSimTime on the game thread; if this ever grows a non-GT caller, add locking. TArray AllAgents; AgentsPassedThroughCounter.GetKeys(AllAgents); diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp index 5460302c3..e90369edb 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp @@ -4,8 +4,6 @@ #include "Subsystems/StatisticSubsystem.h" #include "Actors/FlowCounter.h" -#include "Components/BoxComponent.h" -#include "Kismet/GameplayStatics.h" #include "Subsystems/StatisticActorManagementSubsystem.h" UStatisticSubsystem::UStatisticSubsystem() @@ -17,7 +15,7 @@ void UStatisticSubsystem::Initialize(FSubsystemCollectionBase& Collection) // add the statistic actor management subsystem to the collection Collection.InitializeDependency(); - + Super::Initialize(Collection); // bind to the flow counters changed delegate @@ -30,7 +28,7 @@ void UStatisticSubsystem::Initialize(FSubsystemCollectionBase& Collection) void UStatisticSubsystem::Deinitialize() { Super::Deinitialize(); - + // unbind to the flow counters changed delegate if (UStatisticActorManagementSubsystem* StatisticActorManagementSubsystem = GetWorld()->GetSubsystem()) { @@ -82,13 +80,13 @@ FAgentMeshViewer UStatisticSubsystem::GetSelectedAgentInfoMeshData() FAgentMeshViewer UStatisticSubsystem::GetHoveredAgentInfoMeshData() { - return MoveTemp(HoveredAgentData); + return HoveredAgentData; } void UStatisticSubsystem::UpdateFlowCounters() { if (!GetWorld()){return;} - + UStatisticActorManagementSubsystem* StatisticActorManagementSubsystem = GetWorld()->GetSubsystem(); if (StatisticActorManagementSubsystem) @@ -105,12 +103,13 @@ void UStatisticSubsystem::UpdateFlowCounters() // which should be a larger unrotated 2D plane that represents min max XY for agents to be considered in a flow counter band check bool UStatisticSubsystem::IsAgentLocationInAFlowCounterBand(const FVector& AgentLocation, int32 FlowCounterID) const { - // Dow we have a valid FlowCounterID? - if (!ActiveFlowCounters.IsValidIndex(FlowCounterID)) + if (!ActiveFlowCounters.IsValidIndex(FlowCounterID) || + ActiveFlowCounters[FlowCounterID] == nullptr || + ActiveFlowCounters[FlowCounterID]->IsActorBeingDestroyed()) { return false; } - + AFlowCounter* FlowCounter = ActiveFlowCounters[FlowCounterID]; // First check: is the agent's Z coordinate within this counter's Z bounds? @@ -130,13 +129,15 @@ bool UStatisticSubsystem::IsAgentLocationInAFlowCounterBand(const FVector& Agent // { // continue; // } - + return true; } bool UStatisticSubsystem::HasAgentBeenCountedInFlowCounter(const int32 AgentID, int32 FlowCounterID) const { - if (!ActiveFlowCounters.IsValidIndex(FlowCounterID) && ActiveFlowCounters[FlowCounterID] != nullptr) + if (!ActiveFlowCounters.IsValidIndex(FlowCounterID) || + ActiveFlowCounters[FlowCounterID] == nullptr || + ActiveFlowCounters[FlowCounterID]->IsActorBeingDestroyed()) { return false; } @@ -152,43 +153,6 @@ bool UStatisticSubsystem::HasAgentBeenCountedInFlowCounter(const int32 AgentID, void UStatisticSubsystem::SendArrayDataToFlowCounter(TArray& FlowData, int32 FlowCounterIndex) { - // Check if the flow counters array is empty - it should never be empty as this can only be called from the FlowCounterProcessor - // if (FlowCounters.Num() == 0) - // { - // // attempt to get the flow counters from the world if they are not set - // if (GetWorld()) - // { - // - // - // // As this can be called from outside of the game thread(ParallelFor), we need to get the flow counters from the world in a thread-safe manner. - // AsyncTask(ENamedThreads::GameThread, [this]() - // { - // TArray FoundActors; - // UWorld* World = GetWorld(); - // UGameplayStatics::GetAllActorsOfClass(World, AFlowCounter::StaticClass(), FoundActors); - // if (FoundActors.Num() > 0) - // { - // for (AActor* Actor : FoundActors) - // { - // if (AFlowCounter* FlowCounter = Cast(Actor)) - // { - // // Add the flow counter to the array - // FlowCounters.Add(FlowCounter); - // } - // else - // { - // UE_LOG(LogTemp, Warning, TEXT("Found actor is not a FlowCounter: %s"), *Actor->GetName()); - // } - // } - // } - // }); - // //UGameplayStatics::GetAllActorsOfClass(GetWorld(), AFlowCounter::StaticClass(), FoundActors); - // // if we found any flow counters, we can add them to the FlowCounters array - // - // } - // } - - // Check if the flow counter index is valid if (!FlowCounters.IsValidIndex(FlowCounterIndex)) { UE_LOG(LogTemp, Warning, TEXT("Invalid FlowCounterIndex: %d"), FlowCounterIndex); @@ -196,8 +160,7 @@ void UStatisticSubsystem::SendArrayDataToFlowCounter(TArray& F } AFlowCounter* FlowCounter = FlowCounters[FlowCounterIndex]; - // we may have found a valid index but we can still have a null pointer - if (FlowCounter) + if (FlowCounter && !FlowCounter->IsActorBeingDestroyed()) { FlowCounter->NewAgentData(FlowData); } @@ -205,7 +168,6 @@ void UStatisticSubsystem::SendArrayDataToFlowCounter(TArray& F void UStatisticSubsystem::SendDataToFlowCounter(const FFlowCounterData& FlowData, int32 FlowCounterIndex) { - // Check if the flow counter index is valid if (!ActiveFlowCounters.IsValidIndex(FlowCounterIndex)) { UE_LOG(LogTemp, Warning, TEXT("Invalid FlowCounterIndex: %d"), FlowCounterIndex); @@ -213,8 +175,7 @@ void UStatisticSubsystem::SendDataToFlowCounter(const FFlowCounterData& FlowData } AFlowCounter* FlowCounter = ActiveFlowCounters[FlowCounterIndex]; - // we may have found a valid index but we can still have a null pointer - if (FlowCounter) + if (FlowCounter && !FlowCounter->IsActorBeingDestroyed()) { FlowCounter->ProcessAgentFlowCrossing(FlowData); } @@ -319,4 +280,4 @@ float UStatisticSubsystem::ComputeWeidmannSpeed(float Density, float FreeSpeed, // Weidmann (1993) speed-density relation const float ExpTerm = -1.913f * ((1.f / Density) - (1.f / JamDensity)); return FreeSpeed * (1.f - FMath::Exp(ExpTerm)); -} \ No newline at end of file +} diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/FlowCounter.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/FlowCounter.h index 8eacb71c0..1f3b89425 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/FlowCounter.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/FlowCounter.h @@ -9,7 +9,6 @@ // Forward declarations class UDeformableQuadComponent; -class UStatisticSubsystem; class UBoxComponent; // Delegates @@ -18,12 +17,6 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams( FOnFlowCounterSecond, int32, SimSecond, int32, RollingTotal, const TArray&, PerBucketTotals); // Structs used internally for bucketing agents -> TODO: Move it to the FlowCounterStructs.h file as we may want to use it elsewhere -struct FBuckectTempData -{ - int32 AgentID = 0; - float IntersectionThreshold = 0.0f; -}; - struct FFlowCrossingResult { @@ -276,15 +269,6 @@ class MOBIUSCORE_API AFlowCounter : public AActor UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "FlowCounter|Properties") float PassageFlowIncrement = 50.0f; - /** - * A reference to the statistic subsystem that facilitates communication and integration - * with the broader system managing statistical data within the game or application. - * This subsystem is used to track, update, and register specific statistical elements - * relevant to this class, such as flow counter data or related metrics. - */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "FlowCounter|Properties") - TObjectPtr StatisticSubsystem; - /** * If the flow counter is active and tracking agents passing through it then we need to allow door tracking */ @@ -316,8 +300,6 @@ class MOBIUSCORE_API AFlowCounter : public AActor mutable FRWLock AgentsMapRW; // protects AgentsPassedThroughCounter mutable FRWLock TrackedPrevMapRW; // protects PreviousTrackedAgentLocations TAtomic bTearingDown{false}; - /** A thread-safe queue to handle bucket data due to the possibility of bucket mutations on the game thread */ - TQueue ThreadSafeNewAgentDataQueue = TQueue(); /** */ TQueue ThreadSafeResults; diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/Components/FloorStatsWidget.cpp b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/Components/FloorStatsWidget.cpp index 8c5bf65e6..7bb4e8e83 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/Components/FloorStatsWidget.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/Components/FloorStatsWidget.cpp @@ -484,7 +484,12 @@ void UFloorStatsWidget::BuildDataForImPlotOverlay() return; } - const int32 NumSteps = SimulationFragment->SimulationData.Num(); + if (!SimulationFragment->SimulationData.IsValid()) + { + SendImPlotChartData(); + return; + } + const int32 NumSteps = SimulationFragment->SimulationData->Num(); if (NumSteps == 0) { SendImPlotChartData(); @@ -493,7 +498,7 @@ void UFloorStatsWidget::BuildDataForImPlotOverlay() // Get sorted keys from the TMap to handle non-sequential timestep indices TArray SortedKeys; - SimulationFragment->SimulationData.GetKeys(SortedKeys); + SimulationFragment->SimulationData->GetKeys(SortedKeys); SortedKeys.Sort(); TArray SampleCounts; @@ -505,7 +510,7 @@ void UFloorStatsWidget::BuildDataForImPlotOverlay() for (int32 i = 0; i < SortedKeys.Num(); ++i) { int32 StepCount = 0; - if (const TArray* Samples = SimulationFragment->SimulationData.Find(SortedKeys[i])) + if (const TArray* Samples = SimulationFragment->SimulationData->Find(SortedKeys[i])) { for (const FSimMovementSample& Sample : *Samples) { diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassObserverProcessor/PedestrianInitializeMOP.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassObserverProcessor/PedestrianInitializeMOP.cpp index c34a3263b..2edf6cfec 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassObserverProcessor/PedestrianInitializeMOP.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassObserverProcessor/PedestrianInitializeMOP.cpp @@ -140,7 +140,7 @@ void UPedestrianInitializeMOP::Execute(FMassEntityManager& EntityManager, FMassE const TArrayView& EntityRenderingFragment = Context.GetMutableFragmentView(); // check timestep index is valid - if (SharedAgentMovement.SimulationData.Num() - 1 < CurrentTimeStep) + if (!SharedAgentMovement.SimulationData.IsValid() || SharedAgentMovement.SimulationData->Num() - 1 < CurrentTimeStep) { // log UE_LOG(LogTemp, Warning, TEXT("PedestrianInitializeMOP::Execute CurrentTimeStep not valid")); @@ -148,10 +148,10 @@ void UPedestrianInitializeMOP::Execute(FMassEntityManager& EntityManager, FMassE } // Get the size of the data - //int32 DataSize = SharedAgentMovement.SimulationData[CurrentTimeStep].Num(); + //int32 DataSize = SharedAgentMovement.SimulationData->operator[](CurrentTimeStep).Num(); // Get the first Shared Movement Sample for all entities - TArray AllAgentMovementSamples = SharedAgentMovement.SimulationData[CurrentTimeStep]; + TArray AllAgentMovementSamples = (*SharedAgentMovement.SimulationData)[CurrentTimeStep]; auto Entities = Context.GetEntities(); const TArrayView& EntityCollisions = Context.GetMutableFragmentView(); @@ -189,7 +189,7 @@ void UPedestrianInitializeMOP::Execute(FMassEntityManager& EntityManager, FMassE } else { - for (auto Sample :SharedAgentMovement.SimulationData) + for (auto Sample : *SharedAgentMovement.SimulationData) { if (AllAgentMovementSamples.IsValidIndex(EntityIndexOffset)) { diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/PedestrianMovementProcessor.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/PedestrianMovementProcessor.cpp index 261651d95..7709f3dc7 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/PedestrianMovementProcessor.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/PedestrianMovementProcessor.cpp @@ -119,8 +119,8 @@ void UPedestrianMovementProcessor::Execute(FMassEntityManager& EntityManager, FM const auto& SimulationFragment = Context.GetSharedFragment(); // Use FindChecked if you're confident the key exists (or add checks otherwise) - const TArray* CurrentSamplesPtr = SimulationFragment.SimulationData.Find(CurrentTimeStep); - const TArray* NextSamplesPtr = SimulationFragment.SimulationData.Find(CurrentTimeStep + 1); + const TArray* CurrentSamplesPtr = SimulationFragment.SimulationData->Find(CurrentTimeStep); + const TArray* NextSamplesPtr = SimulationFragment.SimulationData->Find(CurrentTimeStep + 1); if (!CurrentSamplesPtr) { @@ -323,17 +323,20 @@ bool UPedestrianMovementProcessor::IsThereDataToProcess(const FMassExecutionCont { // TODO: This one should be at the start to only check once per call not per loop iteration per call // Check if the shared fragment is empty or not need more methodology to handle this and not check every time executed - if (ExecutionContext.GetSharedFragment().SimulationData.IsEmpty()) + const TSharedPtr>>& SimData = + ExecutionContext.GetSharedFragment().SimulationData; + if (!SimData.IsValid() || SimData->IsEmpty()) { return false; } - - if (CurrentTimeStep >= ExecutionContext.GetSharedFragment().SimulationData.Num()) + + if (CurrentTimeStep >= SimData->Num()) { return false; } - - if (ExecutionContext.GetSharedFragment().SimulationData[CurrentTimeStep].IsEmpty()) + + const TArray* StepSamples = SimData->Find(CurrentTimeStep); + if (!StepSamples || StepSamples->IsEmpty()) { return false; } diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp index 00b28cc78..f724fa4e3 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp @@ -11,14 +11,14 @@ * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. + * all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ @@ -106,7 +106,7 @@ void UAgentDataSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); - // Get the Game Instance + // Get the Game Instance if(UProjectMobiusGameInstance* GameInst = GetMobiusGameInstance(GetWorld())) { // Bind the required Game Instance Delegates @@ -117,7 +117,7 @@ void UAgentDataSubsystem::Initialize(FSubsystemCollectionBase& Collection) // Get the Current Data File set on the instance // JSONDataFile = GameInst->GetPedestrianDataFilePath(); // GetJSONDataFile(JSONDataFile); - + } else { @@ -139,7 +139,7 @@ void UAgentDataSubsystem::Initialize(FSubsystemCollectionBase& Collection) // CalculateMaxEntitiesPermitted(); // } } - + } void UAgentDataSubsystem::Deinitialize() @@ -156,17 +156,17 @@ void UAgentDataSubsystem::Deinitialize() void UAgentDataSubsystem::Tick(float DeltaTime) { Super::Tick(DeltaTime); - + while (ProgressQueue.Dequeue(LoadProgress)) { OnLoadSimulationDataProgress.Broadcast(LoadProgress); } - + while (MaxAgentsQueue.Dequeue(MaxAgents)) { OnMaxAgentCount.Broadcast(MaxAgents); } - + // This temp fix but as we only should be changing loading text at a few key points this should be ok for now while (LoadingTaskQueue.Dequeue(CurrentLoadingTask)) { @@ -366,8 +366,8 @@ FProcessSimulationDataRunnable::FProcessSimulationDataRunnable(FString InJsonDat return; } - - + + // Create the thread -- The thread priority is set to TPri_Normal this may need to be adjusted based on the application Thread = FRunnableThread::Create(this, TEXT("FProcessSimulationDataRunnable"), 0, TPri_Normal); } @@ -404,10 +404,10 @@ bool FProcessSimulationDataRunnable::LoadFileAndDeserialize() bShouldStop = true; return false; } - + // Ensure the simulation file type is set to unknown before loading - successful loading and deserializing will set this to the corresponding file type SimulationFileType = ESimulationFileType::ESFT_Unknown; - //TODO: should really be doing equal not compare + //TODO: should really be doing equal not compare // check what the extension of the file is: JSON ? HDF5 if (FPaths::GetExtension(SimulationDataFilePath).Compare(FString("json"), ESearchCase::Type::IgnoreCase) == 0) // FPaths::GetExtension().Compare() returns 0 if equal! { @@ -446,7 +446,7 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeJSONFile() TSharedRef> JsonReader = TJsonReaderFactory::Create(SimulationDataFile); JSONObject.Reset(); - + // Deserialize JSON Data bool bDeserializeSuccess = FJsonSerializer::Deserialize(JsonReader, JSONObject); @@ -460,7 +460,7 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeJSONFile() bShouldStop = true; return false; } - + // if successful we can set the simulation file type SimulationFileType = ESimulationFileType::ESFT_JSON; @@ -513,6 +513,10 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeHDF5File() } // Convert to Mobius format +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapJuelichBeforeConvert = FMobiusMemSnapshot::Take(TEXT("HDF5_Juelich_BeforeConvert")); + SnapJuelichBeforeConvert.LogAbsolute(); +#endif if (!FHdf5SimulationReader::ConvertJuelichToMobiusFormat( JuelichMeta, Trajectories, Hdf5Data.Meta, Hdf5Data.Entities, Hdf5Data.Samples)) @@ -526,6 +530,9 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeHDF5File() return false; } +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("HDF5_Juelich_AfterConvert")).LogDelta(SnapJuelichBeforeConvert); +#endif UE_LOG(LogTemp, Log, TEXT("Loaded Juelich format HDF5 file: %d entities, %d samples"), Hdf5Data.Entities.Num(), Hdf5Data.Samples.Num()); } @@ -539,9 +546,16 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeHDF5File() // Samples read - also detect if rotation and speed fields exist bool bHasRotationField = true; bool bHasSpeedField = true; +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapHdf5BeforeRead = FMobiusMemSnapshot::Take(TEXT("HDF5_BeforeReadAllSamples")); + SnapHdf5BeforeRead.LogAbsolute(); +#endif HDF5SimulationReader.ReadAllSamples(Hdf5Data.Samples, &bHasRotationField, &bHasSpeedField); Hdf5Data.Meta.bHasRotationData = bHasRotationField; Hdf5Data.Meta.bHasSpeedData = bHasSpeedField; +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("HDF5_AfterReadAllSamples")).LogDelta(SnapHdf5BeforeRead); +#endif UE_LOG(LogTemp, Log, TEXT("Loaded Mobius format HDF5 file: %d entities, %d samples, has rotation: %s, has speed: %s"), Hdf5Data.Entities.Num(), Hdf5Data.Samples.Num(), @@ -572,7 +586,7 @@ void FProcessSimulationDataRunnable::ProcessMetadata(bool& bCalculateTimeBetween { bCalculateTimeBetweenSteps = true; bCalculateMaxTime = true; - + // Check what file we are working with switch (SimulationFileType) { @@ -689,7 +703,7 @@ void FProcessSimulationDataRunnable::ReadHDF5MetadataValues(bool& bCalculateTime // so that FinalizeProgress() still runs and charts render an empty state return; } - + // Did the HDF5 data contain metadata if (Hdf5Data.Meta != FHdf5SimulationMetadata())//TODO: need to improve this equality check logic { @@ -708,10 +722,10 @@ void FProcessSimulationDataRunnable::ReadHDF5MetadataValues(bool& bCalculateTime AgentMovementInfoData.MaxTime = Hdf5Data.Meta.Duration; // get the sampling rate of the simulation from the metadata TimeBetweenSteps = Hdf5Data.Meta.SamplingRate; - + // Calculate the number of samples TargetDataCount = AgentMovementInfoData.MaxTime / TimeBetweenSteps; - + // don't calculate the time between steps bCalculateTimeBetweenSteps = false; @@ -730,18 +744,18 @@ void FProcessSimulationDataRunnable::ReadHDF5MetadataValues(bool& bCalculateTime // get the simulation metadata AgentMovementInfoData.MaxTime = Hdf5Data.Meta.Duration; } - + // Set the target count to the simulation array count - as we know there is simulation data TargetDataCount = Hdf5Data.Samples.Num(); } - + } else { // Set the entity count from count of entities in the hdf5 Entities array if the metadata fields are not present or blank MaxAgents = Hdf5Data.Entities.Num(); } - + } void FProcessSimulationDataRunnable::RunSimulationDataGatheringLoop(bool bCalculateTimeBetweenSteps, bool bCalculateMaxTime) @@ -837,7 +851,7 @@ void FProcessSimulationDataRunnable::RunJsonSimDataGatheringLoop(bool bCalculate // get the sample array for this TArray> JSONSampleArray = JSONSimDataObject->GetArrayField(StringCast("samples")); - + // the number of samples should technically be how many entities there are for this time step NumOfAgentsPerTimeStep.Add(JSONSampleArray.Num()); @@ -993,7 +1007,7 @@ void FProcessSimulationDataRunnable::RunJsonSimDataGatheringLoop(bool bCalculate AgentDataArray[EntityID].MovementData.Push(FMovementPreProcessData(Position)); } - AgentMovementInfoData.SimulationData.Add(CurrentDataCount, MovementSamples); + AgentMovementInfoData.SimulationData->Add(CurrentDataCount, MovementSamples); // Calculate the current percentage of the data loaded float CurrentPercentage = (float)CurrentDataCount / (float)TargetDataCount; @@ -1066,7 +1080,7 @@ void FProcessSimulationDataRunnable::RunHdf5SimDataGatheringLoop(bool bCalculate { // Empty timestep - add empty array and continue NumOfAgentsPerTimeStep.Add(0); - AgentMovementInfoData.SimulationData.Add(TimestepIdx, TArray()); + AgentMovementInfoData.SimulationData->Add(TimestepIdx, TArray()); continue; } @@ -1222,7 +1236,7 @@ void FProcessSimulationDataRunnable::RunHdf5SimDataGatheringLoop(bool bCalculate } // Store movement samples for this timestep - AgentMovementInfoData.SimulationData.Add(TimestepIdx, MovementSamples); + AgentMovementInfoData.SimulationData->Add(TimestepIdx, MovementSamples); // Calculate and report progress float CurrentPercentage = (float)CurrentDataCount / (float)TargetDataCount; @@ -1300,7 +1314,7 @@ void FProcessSimulationDataRunnable::CalculateRotationFromMovement() // We store (timestep index, position) pairs for later sorting TArray> EntityPositions; - for (auto& Pair : AgentMovementInfoData.SimulationData) + for (auto& Pair : *AgentMovementInfoData.SimulationData) { for (const FSimMovementSample& Sample : Pair.Value) { @@ -1357,7 +1371,7 @@ void FProcessSimulationDataRunnable::CalculateRotationFromMovement() // Step 4: Update the rotation value in the actual simulation data int32 Timestep = EntityPositions[i].Key; - for (FSimMovementSample& Sample : AgentMovementInfoData.SimulationData[Timestep]) + for (FSimMovementSample& Sample : (*AgentMovementInfoData.SimulationData)[Timestep]) { if (Sample.EntityID == EntityIdx) { @@ -1418,7 +1432,7 @@ void FProcessSimulationDataRunnable::CalculateSpeedFromMovement() // We store (timestep index, position) pairs for later sorting TArray> EntityPositions; - for (auto& Pair : AgentMovementInfoData.SimulationData) + for (auto& Pair : *AgentMovementInfoData.SimulationData) { for (const FSimMovementSample& Sample : Pair.Value) { @@ -1463,7 +1477,7 @@ void FProcessSimulationDataRunnable::CalculateSpeedFromMovement() // Step 4: Update the speed value in the actual simulation data int32 Timestep = EntityPositions[i].Key; - for (FSimMovementSample& Sample : AgentMovementInfoData.SimulationData[Timestep]) + for (FSimMovementSample& Sample : (*AgentMovementInfoData.SimulationData)[Timestep]) { if (Sample.EntityID == EntityIdx) { @@ -1495,7 +1509,7 @@ void FProcessSimulationDataRunnable::FinalizeProgress() return; } UAgentDataSubsystem* Subsys = OwnerSubsystem.Get(); - + // Perform Animation Preprocessing data here // Broadcast the current percentage of the data loaded if (Subsys) @@ -1512,7 +1526,7 @@ void FProcessSimulationDataRunnable::FinalizeProgress() { Subsys->LoadingTaskQueue.Enqueue(TEXT("Calculating Smoothed Step Movement Brackets...")); } - + CalcSmoothedStepMovementBrackets(AgentDataArray); // let the thread sleep for 0.5 second @@ -1570,14 +1584,14 @@ uint32 FProcessSimulationDataRunnable:: Run() bool bCalculateTimeBetweenSteps = true; bool bCalculateMaxTime = true; - + if (Subsys) { Subsys->LoadingTaskQueue.Enqueue(TEXT("Processing Simulation Metadata...")); } ProcessMetadata(bCalculateTimeBetweenSteps, bCalculateMaxTime); - + if (Subsys) { Subsys->MaxAgentsQueue.Enqueue(MaxAgents); @@ -1662,6 +1676,7 @@ uint32 FProcessSimulationDataRunnable:: Run() #if !UE_BUILD_SHIPPING FMobiusMemSnapshot::Take(TEXT("Run_AfterSamplesFree")).LogDelta(SnapRunStart); #endif + FMemory::Trim(); // hint allocator to return freed JSON pages to OS } // Send the final progress and completion events @@ -1704,15 +1719,9 @@ void FProcessSimulationDataRunnable::Exit() Hdf5Data.Meta = FHdf5SimulationMetadata(); SimulationFileType = ESimulationFileType::ESFT_Unknown; - for (auto& Pair : AgentMovementInfoData.SimulationData) - { - Pair.Value.Empty(); // frees any extra capacity in each TArray - Pair.Value.Shrink(); // frees any extra capacity in each TArray - } - - // Optionally clear large TArrays now to free memory immediately - AgentMovementInfoData.SimulationData.Empty(); - AgentMovementInfoData.SimulationData.Shrink(); + // Drop the TSharedPtr — frees the TMap and all TArrays it owns. + // If SimulationData was already MoveTemp'd to a FSharedStruct, this is a no-op (ptr is null). + AgentMovementInfoData.SimulationData.Reset(); AgentDataArray.Empty(); AgentDataArray.Shrink(); @@ -1738,18 +1747,18 @@ TArray FProcessSimulationDataRunnable::GetMovementSamples(in TArray MovementSamples; // check if the agent id exceeds the max agents - if (AgentID >= AgentMovementInfoData.SimulationData.Num()) + if (!AgentMovementInfoData.SimulationData.IsValid() || AgentID >= AgentMovementInfoData.SimulationData->Num()) { // throw error message } else { // loop through the simulation data and get the movement samples - for (int32 i = 0; i < AgentMovementInfoData.SimulationData.Num(); i++) + for (int32 i = 0; i < AgentMovementInfoData.SimulationData->Num(); i++) { if (bShouldStop) break; // loop through the movement samples for this time step - for (FSimMovementSample MovementSample : AgentMovementInfoData.SimulationData[i]) + for (FSimMovementSample MovementSample : (*AgentMovementInfoData.SimulationData)[i]) { if (bShouldStop) break; if (MovementSample.EntityID == AgentID) @@ -1768,8 +1777,8 @@ void FProcessSimulationDataRunnable::CalcSmoothedStepMovementBrackets(const TArr // Build O(1) lookup: SampleIndex[timestep][entityID] -> FSimMovementSample* // This replaces the O(S) linear scan per SetAnimPt call with O(1) hash lookup TMap> SampleIndex; - SampleIndex.Reserve(AgentMovementInfoData.SimulationData.Num()); - for (auto& Pair : AgentMovementInfoData.SimulationData) + SampleIndex.Reserve(AgentMovementInfoData.SimulationData->Num()); + for (auto& Pair : *AgentMovementInfoData.SimulationData) { if (bShouldStop) break; TMap& IndexMap = SampleIndex.Add(Pair.Key); @@ -1889,13 +1898,13 @@ EPedestrianMovementBracket FProcessSimulationDataRunnable::CalculateStepAnimatio { FVatMovementFrames Band = AvatarGaitSpeedBands[5]; // Default to the last band // Fast loop through the GaitSpeedBands, testing CurrentSpeed against the HighVal, in ascending order, to assign the MovementBracket - // We have assumed that these gait parameters apply to avatars of 1.72m height + // We have assumed that these gait parameters apply to avatars of 1.72m height int iBracket = 0; for (; iBracket < (sizeof(AvatarGaitSpeedBands) / sizeof(FVatMovementFrames) - 1); iBracket++) { if (CurrentSpeed < AvatarGaitSpeedBands[iBracket].HighSpeed) { break; } - } + } StepsPerSecond = AvatarGaitSpeedBands[iBracket].AnimatedStepLength / CurrentSpeed; diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index 34a5cdc20..2f8de52ec 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -137,8 +137,15 @@ void UMassEntitySpawnSubsystem::SpawnMassEntityPedestrians(int32 NumberOfPedestr { if (auto StatSubsystem = GetWorld()->GetSubsystem()) { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapFlowBefore = FMobiusMemSnapshot::Take(TEXT("FlowReset_Before")); + SnapFlowBefore.LogAbsolute(); +#endif StatSubsystem->ResetFlowCounters(); bHasResetFlowCounters = true; +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("FlowReset_After")).LogDelta(SnapFlowBefore); +#endif } } @@ -322,16 +329,17 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() // prevent the ~900MB of TMap/TArray memory from being reclaimed. if (FSimulationFragment* Frag = SharedSimulationFragment.GetPtr()) { - for (auto& Pair : Frag->SimulationData) - { - Pair.Value.Empty(); - Pair.Value.Shrink(); - } - Frag->SimulationData.Empty(); - Frag->SimulationData.Shrink(); + // Reset the TSharedPtr — frees the 4 GB TMap independently of the Mass archetype + // that permanently holds the FSimulationFragment struct. + Frag->SimulationData.Reset(); } SharedSimulationFragment = FSharedStruct(); + // Second GC pass now that template + SimulationData are both released. + // The first pass (above) ran before DestroyTemplate, so the archetype still held refs then. + // This pass lets the allocator reclaim pages sooner after the 4 GB drop. + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + #if !UE_BUILD_SHIPPING FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSharedFragReset")).LogDelta(SnapSwitchStart); #endif @@ -442,6 +450,7 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() // PedestrianInitializeMOP fires after SpawnMaxPedestrians destroys the runnable, // so SetEntityInfoByIndex / SetEntityRenderingByIndex must read from here instead. AgentDataSubsystem->CachedEntityData = MoveTemp(AgentDataSubsystem->JsonDataRunnable->Hdf5Data.Entities); + AgentDataSubsystem->CachedEntityData.Shrink(); } //UE_LOG(LogTemp, Warning, TEXT("Building Pedestrian Movement Fragment Data")); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/Fragments/SharedFragments/SimulationFragment.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/Fragments/SharedFragments/SimulationFragment.h index 3a186cb62..8262e317d 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/Fragments/SharedFragments/SimulationFragment.h +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/Fragments/SharedFragments/SimulationFragment.h @@ -80,8 +80,11 @@ struct PROJECTMOBIUS_API FSimulationFragment : public FMassSharedFragment public: // TODO: Add buffer method and not store all data in this struct // TODO: This Map logic needs improving as it is not efficient with large data sets and looping over all data is poor - /** TMap for data the key is time and value is struct array of FSimMovementSample */ - TMap> SimulationData = TMap>(); + /** TMap for data the key is time and value is struct array of FSimMovementSample. + * Heap-allocated via TSharedPtr so the 4 GB data block can be freed independently + * of the Mass archetype that permanently holds this struct. Call SimulationData.Reset() + * on file switch to release the allocation without waiting for archetype destruction. */ + TSharedPtr>> SimulationData = MakeShared>>(); UPROPERTY() float MaxTime = 0.0f; diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusEditor.Target.cs b/UnrealFolder/ProjectMobius/Source/ProjectMobiusEditor.Target.cs index 7355eacc2..af72aadd7 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobiusEditor.Target.cs +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusEditor.Target.cs @@ -18,6 +18,6 @@ public ProjectMobiusEditorTarget( TargetInfo Target) : base(Target) private void RegisterModulesCreatedByRider() { - ExtraModuleNames.AddRange(new string[] { "MobiusWidgets", "Visualization", "MobiusCore", "MobiusEditor" }); + ExtraModuleNames.AddRange(new string[] { "MobiusWidgets", "Visualization", "MobiusCore", "MobiusEditor", "ProjectMobiusTests" }); } } diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs index 5bd2eef17..22478676e 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs @@ -12,6 +12,7 @@ public ProjectMobiusTests(ReadOnlyTargetRules Target) : base(Target) "Core", "CoreUObject", "Engine", + "AutomationController", "ProjectMobius", "Hdf5DataPlugin", "MassEntity", From 446a4a3c610686b2748d7cf2b058a05afdc4e44a Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:30:00 +1200 Subject: [PATCH 04/12] Add memory tracing and async-safety fixes Instrument memory tracing and improve async/thread safety across multiple systems. Key changes: - Add and include Util/MemoryTraceHelper and define LogMobiusMemory for MobiusCore; add FMobiusMemSnapshot probes in FlowCounter, StatisticSubsystem, StatisticActorManagementSubsystem, MassEntitySpawnSubsystem and AgentDataSubsystem to help diagnose memory retention during file switches and lifecycle operations. - HeatmapSubsystem: snapshot Heatmaps into TWeakObjectPtr arrays for safe iteration on worker threads and re-validate actors before use. - RuntimeMeshBuilder: reuse RuntimeDatasmithAnchor (call Reset), guard async work with TWeakObjectPtr, and capture ExistingRunnable by value when dispatching deletion to the game thread. - MobiusIpcClient: marshal OnMessage delegate execution to the game thread using AsyncTask and a weak/shared pointer check to avoid touching UObject state from background thread. - AgentDataSubsystem: add ClearPerFileState to drop per-file caches and drain queues between file switches. - Add MobiusCore dependency to ProjectMobiusTests.Build.cs so tests can access the new utilities. Overall the patch is focused on reducing transient UObject retention, eliminating race conditions in background tasks, and providing runtime memory diagnostics to trace leaks and allocator behavior. --- .../MobiusCore/Private/Actors/FlowCounter.cpp | 35 ++++ .../BuildingGenerator/RuntimeMeshBuilder.cpp | 68 ++++--- .../Private/Components/MobiusIpcClient.cpp | 25 ++- .../Source/MobiusCore/Private/MobiusCore.cpp | 5 + .../Private/Subsystems/HeatmapSubsystem.cpp | 103 +++++----- .../StatisticActorManagementSubsystem.cpp | 22 ++- .../Private/Subsystems/StatisticSubsystem.cpp | 26 ++- .../Public/Util}/MemoryTraceHelper.h | 2 +- .../MassAI/SubSystems/AgentDataSubsystem.cpp | 31 ++- .../SubSystems/MassEntitySpawnSubsystem.cpp | 177 +++++++++++++++++- .../MassAI/SubSystems/AgentDataSubsystem.h | 10 +- .../Private/Tests/AgentDataMemoryTest.cpp | 2 +- .../ProjectMobiusTests.Build.cs | 1 + 13 files changed, 413 insertions(+), 94 deletions(-) rename UnrealFolder/ProjectMobius/Source/{ProjectMobius/Public/MassAI/SubSystems => MobiusCore/Public/Util}/MemoryTraceHelper.h (96%) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp index f87f9e881..a121452cb 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp @@ -9,6 +9,7 @@ #include "Subsystems/StatisticActorManagementSubsystem.h" #include "Subsystems/TimeDilationSubSystem.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" +#include "Util/MemoryTraceHelper.h" // Shared base material for all FlowCounters (loaded once, reused). static TWeakObjectPtr GFlowCounterBaseMaterial; @@ -441,6 +442,12 @@ void AFlowCounter::BeginPlay() void AFlowCounter::EndPlay(const EEndPlayReason::Type EndPlayReason) { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapFcEndStart = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("FC_EndPlay_Start[%s:%d/%d]"), + *GetName(), AgentsPassedThroughCounter.Num(), PreviousTrackedAgentLocations.Num())); +#endif + bTearingDown.Store(true); // visible to all threads // 1) Unregister from subsystems so no NEW calls are scheduled @@ -470,6 +477,10 @@ void AFlowCounter::EndPlay(const EEndPlayReason::Type EndPlayReason) PreviousTrackedAgentLocations.Empty(); } +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(FString::Printf(TEXT("FC_EndPlay_End[%s]"), *GetName())).LogDelta(SnapFcEndStart); +#endif + Super::EndPlay(EndPlayReason); } @@ -932,6 +943,23 @@ void AFlowCounter::RemoveFlowCounterToSubsystem() void AFlowCounter::ResetFlowCounterTrackingData() { +#if !UE_BUILD_SHIPPING + // Skip the probe log when there's nothing to reset. At spawn this method fires + // four times per counter (from MoveGatePillar → UpdateFlowCounterTriggerBox + // path x2 pillars) with empty maps, spamming the log. Only probe when real + // data is being cleared. + const bool bHadData = AgentsPassedThroughCounter.Num() > 0 + || PreviousTrackedAgentLocations.Num() > 0 + || FlowCounterCount.load() > 0; + TOptional SnapResetStart; + if (bHadData) + { + SnapResetStart = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("FC_Reset_Start[%s:passed=%d,tracked=%d,buckets=%d]"), + *GetName(), AgentsPassedThroughCounter.Num(), PreviousTrackedAgentLocations.Num(), FlowCounterBucketData.Num())); + } +#endif + // Reset the flow counter count to 0 FlowCounterCount.exchange(0); // Clear the previous tracked agent locations @@ -953,6 +981,13 @@ void AFlowCounter::ResetFlowCounterTrackingData() Bucket.AgentIDs.Empty(); Bucket.AgentCount = 0; } + +#if !UE_BUILD_SHIPPING + if (SnapResetStart.IsSet()) + { + FMobiusMemSnapshot::Take(FString::Printf(TEXT("FC_Reset_End[%s]"), *GetName())).LogDelta(SnapResetStart.GetValue()); + } +#endif } void AFlowCounter::NewSimTime(float UpdatedTime) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp index b9b319ab3..c969ea40c 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp @@ -388,11 +388,14 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // Make sure no residual data of the procedural mesh comp exists ResetMeshCollisionAndPhysics(); + // Reuse the anchor across loads. Destroying + respawning defers the old + // SceneImporter's teardown past the next LoadFile, so its AssetElementMapping + // still holds TObjectPtrs to the prior scene's RuntimeMesh/BodySetup objects + // when GC runs — 837+ transient UObjects survive every switch. Reset() clears + // those maps synchronously before LoadFile repopulates them. if (RuntimeDatasmithAnchor) { - auto ActorToDestroy = RuntimeDatasmithAnchor; - ActorToDestroy->Destroy(); - RuntimeDatasmithAnchor = nullptr; + RuntimeDatasmithAnchor->Reset(); } // Double-flush queues in case async work enqueued new items during teardown @@ -424,8 +427,12 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // set the flag to indicate this is a datasmith file bIsDatasmithAsset = true; - // spawn a runtime datasmtih actor to load the mesh - RuntimeDatasmithAnchor = GetWorld()->SpawnActor(); + // Spawn only on first load; subsequent loads reuse the same actor so the + // plugin's SceneImporter teardown runs before the next scene populates. + if (RuntimeDatasmithAnchor == nullptr) + { + RuntimeDatasmithAnchor = GetWorld()->SpawnActor(); + } if(MeshFileName.Contains(".udatasmith")) { @@ -457,31 +464,36 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // import the mesh data into the anchor RuntimeDatasmithAnchor->LoadFile(MeshFileName); - // Async task to check if the scene is loaded - Async(EAsyncExecution::ThreadPool, [this]() + // Async task to check if the scene is loaded. + // Poll on thread pool, then marshal material setup back to GT — use + // TWeakObjectPtr so the actor being torn down during the poll doesn't + // leave the worker / GT lambdas dereferencing a destroyed this. + TWeakObjectPtr WeakThis(this); + Async(EAsyncExecution::ThreadPool, [WeakThis]() { FPlatformProcess::Sleep(5.0f); - // log building and receiving - UE_LOG(LogTemp, Warning, TEXT("1Building: %d, Receiving: %d"), RuntimeDatasmithAnchor->bBuilding, RuntimeDatasmithAnchor->IsReceiving()); - while (RuntimeDatasmithAnchor->bBuilding || RuntimeDatasmithAnchor->IsReceiving()) + ARuntimeMeshBuilder* Self = WeakThis.Get(); + if (!Self || !Self->RuntimeDatasmithAnchor) return; + UE_LOG(LogTemp, Warning, TEXT("1Building: %d, Receiving: %d"), Self->RuntimeDatasmithAnchor->bBuilding, Self->RuntimeDatasmithAnchor->IsReceiving()); + while (Self->RuntimeDatasmithAnchor->bBuilding || Self->RuntimeDatasmithAnchor->IsReceiving()) { - // log building and receiving - UE_LOG(LogTemp, Warning, TEXT("2Building: %d, Receiving: %d"), RuntimeDatasmithAnchor->bBuilding, RuntimeDatasmithAnchor->IsReceiving()); - // sleep for 0.05 seconds + UE_LOG(LogTemp, Warning, TEXT("2Building: %d, Receiving: %d"), Self->RuntimeDatasmithAnchor->bBuilding, Self->RuntimeDatasmithAnchor->IsReceiving()); FPlatformProcess::Sleep(0.05f); + Self = WeakThis.Get(); + if (!Self || !Self->RuntimeDatasmithAnchor) return; } - // log building and receiving - UE_LOG(LogTemp, Warning, TEXT("3Building: %d, Receiving: %d"), RuntimeDatasmithAnchor->bBuilding, RuntimeDatasmithAnchor->IsReceiving()); + UE_LOG(LogTemp, Warning, TEXT("3Building: %d, Receiving: %d"), Self->RuntimeDatasmithAnchor->bBuilding, Self->RuntimeDatasmithAnchor->IsReceiving()); - }, [this]() + }, [WeakThis]() { - - AsyncTask(ENamedThreads::GameThread,[this] { - CreateDatasmithMaterials(); - // lights imported by datasmith can cause performance issues, so may need to disable cast shadows or reduce - // the size of point light radius and intensitys - - bIsResettingForNewLoad = false; + AsyncTask(ENamedThreads::GameThread, [WeakThis] { + if (ARuntimeMeshBuilder* Self = WeakThis.Get()) + { + Self->CreateDatasmithMaterials(); + // lights imported by datasmith can cause performance issues, so may need to disable cast shadows or reduce + // the size of point light radius and intensitys + Self->bIsResettingForNewLoad = false; + } }); }); @@ -574,9 +586,10 @@ void ARuntimeMeshBuilder::AsyncUpdateMesh(const FString PathToMesh) ExistingRunnable->Stop(); ExistingRunnable->Exit(); - AsyncTask(ENamedThreads::GameThread, [&ExistingRunnable] + // Capture by value — ExistingRunnable is a stack-local pointer and goes + // out of scope when this function returns, before the GT task runs. + AsyncTask(ENamedThreads::GameThread, [ExistingRunnable] { - // Delete the existing runnable on the game thread delete ExistingRunnable; }); } @@ -662,9 +675,10 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() ExistingRunnable->Stop(); ExistingRunnable->Exit(); - AsyncTask(ENamedThreads::GameThread, [&ExistingRunnable] + // Capture by value — ExistingRunnable is a stack-local pointer and goes + // out of scope when this function returns, before the GT task runs. + AsyncTask(ENamedThreads::GameThread, [ExistingRunnable] { - // Delete the existing runnable on the game thread delete ExistingRunnable; }); } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Components/MobiusIpcClient.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Components/MobiusIpcClient.cpp index 9b6bb7dce..2022f192e 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Components/MobiusIpcClient.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Components/MobiusIpcClient.cpp @@ -1,4 +1,5 @@ #include "Components/MobiusIpcClient.h" +#include "Async/Async.h" #include "HAL/PlatformProcess.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" @@ -175,9 +176,19 @@ uint32 FMobiusIpcClient::Run() break; } + // Delegate listeners (e.g. IpcSubsystem::OnIpcMessage) touch UObject + // state — marshal to GT and re-validate the client is still alive. if (OnMessage.IsBound()) { - OnMessage.Execute(Buf); + TWeakPtr WeakSelf = AsShared(); + AsyncTask(ENamedThreads::GameThread, + [WeakSelf, Payload = MoveTemp(Buf)]() + { + if (TSharedPtr Self = WeakSelf.Pin()) + { + Self->OnMessage.ExecuteIfBound(Payload); + } + }); } } #elif PLATFORM_MAC @@ -219,9 +230,19 @@ uint32 FMobiusIpcClient::Run() break; } + // Delegate listeners (e.g. IpcSubsystem::OnIpcMessage) touch UObject + // state — marshal to GT and re-validate the client is still alive. if (OnMessage.IsBound()) { - OnMessage.Execute(Buf); + TWeakPtr WeakSelf = AsShared(); + AsyncTask(ENamedThreads::GameThread, + [WeakSelf, Payload = MoveTemp(Buf)]() + { + if (TSharedPtr Self = WeakSelf.Pin()) + { + Self->OnMessage.ExecuteIfBound(Payload); + } + }); } } else diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/MobiusCore.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/MobiusCore.cpp index 53859459a..b59fca250 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/MobiusCore.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/MobiusCore.cpp @@ -23,6 +23,11 @@ #include "MobiusCore.h" #include "IMobiusErrorReporter.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" +#include "Util/MemoryTraceHelper.h" + +#if !UE_BUILD_SHIPPING +DEFINE_LOG_CATEGORY(LogMobiusMemory); +#endif #define LOCTEXT_NAMESPACE "FMobiusCoreModule" diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp index daf90ee66..0fccbf4bc 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp @@ -542,34 +542,38 @@ void UHeatmapSubsystem::RunAsyncHeatmapUpdate_Mpmc( const TArray& FallbackLocations) { //TRACE_CPUPROFILER_EVENT_SCOPE("RunAsyncHeatmapUpdate_Mpmc"); - TWeakObjectPtr WeakSelf(this); - Async(EAsyncExecution::Thread, [WeakSelf, ValidLocations, FallbackLocations]() + // Snapshot Heatmaps on GT as weak ptrs so the worker iterates a stable array + // and can re-validate each element before use. Direct iteration of the live + // TArray races with GT add/remove and actor GC. + TArray> HeatmapsSnapshot; + HeatmapsSnapshot.Reserve(Heatmaps.Num()); + for (AHeatmapPixelTextureVisualizer* HM : Heatmaps) { HeatmapsSnapshot.Add(HM); } + + Async(EAsyncExecution::Thread, [HeatmapsSnapshot, ValidLocations, FallbackLocations]() { - if (!WeakSelf.IsValid()) return; - UHeatmapSubsystem* Self = WeakSelf.Get(); //TRACE_CPUPROFILER_EVENT_SCOPE_STR("Heatmap Subsystem work task"); - - ParallelFor(Self->Heatmaps.Num(), [&](int32 i) + ParallelFor(HeatmapsSnapshot.Num(), [&](int32 i) { - if (Self->Heatmaps[i] && !Self->Heatmaps[i]->IsHidden()) + AHeatmapPixelTextureVisualizer* HM = HeatmapsSnapshot[i].Get(); + if (!HM || !IsValid(HM)) return; + if (!HM->IsHidden() && ValidLocations.IsValidIndex(i)) { - Self->Heatmaps[i]->UpdateHeatmapWithMultipleAgents(ValidLocations[i]); + HM->UpdateHeatmapWithMultipleAgents(ValidLocations[i]); } - else if (Self->Heatmaps[i]) + else { - Self->Heatmaps[i]->UpdateHeatmapAgentCount(FallbackLocations); + HM->UpdateHeatmapAgentCount(FallbackLocations); } }); }, - [WeakSelf]() + [HeatmapsSnapshot]() { - if (!WeakSelf.IsValid()) return; - UHeatmapSubsystem* Self = WeakSelf.Get(); - ParallelFor(Self->Heatmaps.Num(), [&](int32 i) + ParallelFor(HeatmapsSnapshot.Num(), [&](int32 i) { - if (Self->Heatmaps[i]) + AHeatmapPixelTextureVisualizer* HM = HeatmapsSnapshot[i].Get(); + if (HM && IsValid(HM)) { - Self->Heatmaps[i]->UpdateHeatmapTextureRender(); + HM->UpdateHeatmapTextureRender(); } }); }); @@ -580,37 +584,38 @@ void UHeatmapSubsystem::RunAsyncHeatmapUpdate(const TArray& LocationArr const TArray>& ValidLocations) { ///TRACE_CPUPROFILER_EVENT_SCOPE("RunAsyncHeatmapUpdate"); - TWeakObjectPtr WeakSelf(this); - Async(EAsyncExecution::Thread, [WeakSelf, LocationArray, ValidLocations]() - { - if (!WeakSelf.IsValid()) - return; - UHeatmapSubsystem* Self = WeakSelf.Get(); - //TRACE_CPUPROFILER_EVENT_SCOPE_STR("Heatmap Subsystem work task"); - - ParallelFor(Self->Heatmaps.Num(), [&](int32 i) - { - if (Self->Heatmaps[i] && !Self->Heatmaps[i]->IsHidden()) - { - Self->Heatmaps[i]->UpdateHeatmapWithMultipleAgents(ValidLocations[i]); - } - else if (Self->Heatmaps[i]) - { - Self->Heatmaps[i]->UpdateHeatmapAgentCount(LocationArray); - } - }); - }, - [WeakSelf]() - { - if (!WeakSelf.IsValid()) - return; - UHeatmapSubsystem* Self = WeakSelf.Get(); - ParallelFor(Self->Heatmaps.Num(), [&](int32 i) - { - if (Self->Heatmaps[i] && !Self->Heatmaps[i]->IsHidden()) - { - Self->Heatmaps[i]->UpdateHeatmapTextureRender(); - } - }); - }); + // Snapshot Heatmaps on GT as weak ptrs so the worker iterates a stable array + // and can re-validate each element before use. + TArray> HeatmapsSnapshot; + HeatmapsSnapshot.Reserve(Heatmaps.Num()); + for (AHeatmapPixelTextureVisualizer* HM : Heatmaps) { HeatmapsSnapshot.Add(HM); } + + Async(EAsyncExecution::Thread, [HeatmapsSnapshot, LocationArray, ValidLocations]() + { + //TRACE_CPUPROFILER_EVENT_SCOPE_STR("Heatmap Subsystem work task"); + ParallelFor(HeatmapsSnapshot.Num(), [&](int32 i) + { + AHeatmapPixelTextureVisualizer* HM = HeatmapsSnapshot[i].Get(); + if (!HM || !IsValid(HM)) return; + if (!HM->IsHidden() && ValidLocations.IsValidIndex(i)) + { + HM->UpdateHeatmapWithMultipleAgents(ValidLocations[i]); + } + else + { + HM->UpdateHeatmapAgentCount(LocationArray); + } + }); + }, + [HeatmapsSnapshot]() + { + ParallelFor(HeatmapsSnapshot.Num(), [&](int32 i) + { + AHeatmapPixelTextureVisualizer* HM = HeatmapsSnapshot[i].Get(); + if (HM && IsValid(HM) && !HM->IsHidden()) + { + HM->UpdateHeatmapTextureRender(); + } + }); + }); } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticActorManagementSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticActorManagementSubsystem.cpp index ca672bf93..48f2c28d2 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticActorManagementSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticActorManagementSubsystem.cpp @@ -2,6 +2,7 @@ #include "Subsystems/StatisticActorManagementSubsystem.h" +#include "Util/MemoryTraceHelper.h" UStatisticActorManagementSubsystem::UStatisticActorManagementSubsystem() { @@ -12,7 +13,12 @@ void UStatisticActorManagementSubsystem::AddFlowCounter(AFlowCounter* FlowCounte if (!FlowCounters.Contains(FlowCounter)) { FlowCounters.Add(FlowCounter); - + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take( + FString::Printf(TEXT("FCMgr_Add[count=%d]"), FlowCounters.Num())).LogAbsolute(); +#endif + // Notify listeners that the flow counters have changed OnFlowCountersChanged.ExecuteIfBound(); } @@ -22,12 +28,22 @@ void UStatisticActorManagementSubsystem::RemoveFlowCounter(AFlowCounter* FlowCou { if (FlowCounters.Contains(FlowCounter)) { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapBefore = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("FCMgr_RemoveStart[count=%d]"), FlowCounters.Num())); +#endif + int32 Index = FlowCounters.Find(FlowCounter); FlowCounters.RemoveAt(Index); - + // once we remove it we need to correct the indices of the remaining flow counters - + // Notify listeners that the flow counters have changed OnFlowCountersChanged.ExecuteIfBound(); + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take( + FString::Printf(TEXT("FCMgr_RemoveEnd[count=%d]"), FlowCounters.Num())).LogDelta(SnapBefore); +#endif } } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp index e90369edb..5265f7f39 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp @@ -5,6 +5,7 @@ #include "Actors/FlowCounter.h" #include "Subsystems/StatisticActorManagementSubsystem.h" +#include "Util/MemoryTraceHelper.h" UStatisticSubsystem::UStatisticSubsystem() { @@ -27,6 +28,12 @@ void UStatisticSubsystem::Initialize(FSubsystemCollectionBase& Collection) void UStatisticSubsystem::Deinitialize() { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapDeinit = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("StatSub_Deinit_Start[flow=%d,active=%d,agents=%d]"), + FlowCounters.Num(), ActiveFlowCounters.Num(), PedestrianAgentData.Num())); +#endif + Super::Deinitialize(); // unbind to the flow counters changed delegate @@ -34,6 +41,10 @@ void UStatisticSubsystem::Deinitialize() { StatisticActorManagementSubsystem->OnFlowCountersChanged.Unbind(); } + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("StatSub_Deinit_End")).LogDelta(SnapDeinit); +#endif } void UStatisticSubsystem::OnWorldBeginPlay(UWorld& InWorld) @@ -91,8 +102,10 @@ void UStatisticSubsystem::UpdateFlowCounters() if (StatisticActorManagementSubsystem) { - // TODO: not really efficient to clear and re-add the flow counters every time this is called but for now it is fine - FlowCounters.Empty(); + // Copy-assign is enough — TArray::operator= replaces contents. Previous code + // also called Empty() first which triggered an extra allocator trip on every + // FlowCounter Add/Remove broadcast (O(N²) churn at startup with 100 counters). + // TODO: switch to delta add/remove instead of full-array rebroadcast. FlowCounters = StatisticActorManagementSubsystem->FlowCounters; } } @@ -183,6 +196,11 @@ void UStatisticSubsystem::SendDataToFlowCounter(const FFlowCounterData& FlowData void UStatisticSubsystem::ResetFlowCounters() { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapResetAll = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("StatSub_ResetAll_Start[flow=%d]"), FlowCounters.Num())); +#endif + for (AFlowCounter* FlowCounter : FlowCounters) { if (FlowCounter) @@ -194,6 +212,10 @@ void UStatisticSubsystem::ResetFlowCounters() UE_LOG(LogTemp, Warning, TEXT("FlowCounter is null")); } } + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("StatSub_ResetAll_End")).LogDelta(SnapResetAll); +#endif } void UStatisticSubsystem::AddRemoveActiveFlowCounter(AFlowCounter* FlowCounter, bool bAddToActiveCounters) diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/MemoryTraceHelper.h similarity index 96% rename from UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h rename to UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/MemoryTraceHelper.h index eb771d4f1..a3c624550 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/MemoryTraceHelper.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/MemoryTraceHelper.h @@ -22,7 +22,7 @@ #include "HAL/PlatformTime.h" #include "Logging/LogMacros.h" -PROJECTMOBIUS_API DECLARE_LOG_CATEGORY_EXTERN(LogMobiusMemory, Log, All); +MOBIUSCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogMobiusMemory, Log, All); struct FMobiusMemSnapshot { diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp index f724fa4e3..97cb5fa89 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp @@ -42,11 +42,7 @@ #include "MassAI/SubSystems/MassRepresentation/MRS_RepresentationSubsystem.h" #include "Subsystems/LoadingSubsystem.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" -#include "MassAI/SubSystems/MemoryTraceHelper.h" - -#if !UE_BUILD_SHIPPING -DEFINE_LOG_CATEGORY(LogMobiusMemory); -#endif +#include "Util/MemoryTraceHelper.h" namespace { @@ -314,6 +310,27 @@ void UAgentDataSubsystem::UpdateMaxAgentCount(int32 NewMaxAgentCount) UE_LOG(LogTemp, Warning, TEXT("New Max Agent Count: %d"), MaxAgents); } +void UAgentDataSubsystem::ClearPerFileState() +{ + // CachedEntityData holds the previous file's FHdf5EntityData array. It was + // only ever overwritten when the next file's BuildFrag ran, so between + // switches the prior payload stayed live. Drop it now. + CachedEntityData.Empty(); + CachedEntityData.Shrink(); + + // Drain the Tick-fed queues so they don't retain slot capacity from the + // prior file. TQueue has no Shrink; dequeue loop is cheapest. + { + float Tmp = 0.f; while (ProgressQueue.Dequeue(Tmp)) { } + } + { + int32 Tmp = 0; while (MaxAgentsQueue.Dequeue(Tmp)) { } + } + { + FString Tmp; while (LoadingTaskQueue.Dequeue(Tmp)) { } + } +} + bool UAgentDataSubsystem::CheckFilePathExists(FString FilePath) { if (FPaths::FileExists(FilePath)) @@ -1711,6 +1728,10 @@ void FProcessSimulationDataRunnable::Exit() if (HDF5SimulationReader.IsOpen()) { HDF5SimulationReader.CloseFile(); + // TODO: add a GarbageCollect() wrapper on FHdf5SimulationReader that calls + // H5garbage_collect() — libhdf5 retains per-property-list caches across + // CloseFile and they're typically 10-30MB per file. Requires exposing + // the HDF5 C API header through the plugin's public include path first. } Hdf5Data.Entities.Empty(); Hdf5Data.Entities.Shrink(); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index 2f8de52ec..c2f3f113b 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -50,7 +50,7 @@ #include "MassAI/Fragments/EntityTags/PedestrianCollisionTags.h" #include "MassAI/Fragments/SharedFragments/RepresentationFragments/AgentNiagaraDataFrag.h" #include "MassAI/SubSystems/PedestrianSignalSubsystem.h" -#include "MassAI/SubSystems/MemoryTraceHelper.h" +#include "Util/MemoryTraceHelper.h" #include "Subsystems/StatisticSubsystem.h" class UTimeDilationSubSystem; @@ -207,9 +207,32 @@ void UMassEntitySpawnSubsystem::ClearNiagaraSim() auto* ExistingActor = Cast(UGameplayStatics::GetActorOfClass(GetWorld(), ANiagaraAgentRepActor::StaticClass())); if (ExistingActor) { +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot NiaPrev = FMobiusMemSnapshot::Take(TEXT("Niagara_BeforeClearSimCache")); +#endif ExistingActor->GetNiagaraComponent()->ClearSimCache(true); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("Niagara_AfterClearSimCache")); + S.LogDelta(NiaPrev); + NiaPrev = S; + } +#endif ExistingActor->GetNiagaraComponent()->DeactivateImmediate(); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("Niagara_AfterDeactivate")); + S.LogDelta(NiaPrev); + NiaPrev = S; + } +#endif ExistingActor->GetNiagaraComponent()->DestroyInstanceNotComponent(); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("Niagara_AfterDestroyInstance")); + S.LogDelta(NiaPrev); + } +#endif } } @@ -284,6 +307,23 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() #if !UE_BUILD_SHIPPING FMobiusMemSnapshot SnapSwitchStart = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterRunnableCleanup")); SnapSwitchStart.LogAbsolute(); + FMobiusMemSnapshot SnapPrev = SnapSwitchStart; +#endif + + // Drop per-file caches (CachedEntityData + subsystem TQueues) BEFORE we destroy + // entities and the template. Those caches are never reached by GC and were + // observed to hold prior-file residue across switches. + if (AgentDataSubsystem) + { + AgentDataSubsystem->ClearPerFileState(); + } + +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterClearPerFileState")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } #endif // as our capsule objects are bound to the world and the world is never destroyed, we need to ensure that the @@ -297,10 +337,35 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() } } +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterCapsuleDestroy")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + // Destroy any existing spawned pedestrians and clear the Niagara simulation DestroyAllSpawnedPedestrians(); + +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterDestroyAllSpawned")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + ClearNiagaraSim(); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterClearNiagara")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + // Empty out the handles array SpawnedEntityPedestrianHandles.Empty(); @@ -309,7 +374,11 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); #if !UE_BUILD_SHIPPING - FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterDestroyAllAndGC")).LogDelta(SnapSwitchStart); + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterFirstGC")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } #endif // Destroy old template entry in the registry — FindOrAddTemplate never replaces existing @@ -319,9 +388,25 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() TemplateRegistryInstance.DestroyTemplate(RegisteredPedestrianTemplateID); RegisteredPedestrianTemplateID.Invalidate(); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterDestroyTemplate")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + // Reset the template data PedestrianTemplateData = FMassEntityTemplateData(); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterTemplateDataReset")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + // Drop our ref to the old simulation fragment so it can be freed now that the // TemplateRegistryInstance has also released its ref. // We aggressively clear the data map first because Mass AI may still hold @@ -333,15 +418,101 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() // that permanently holds the FSimulationFragment struct. Frag->SimulationData.Reset(); } + +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSimDataReset")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + SharedSimulationFragment = FSharedStruct(); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSharedStructCleared")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + // Second GC pass now that template + SimulationData are both released. // The first pass (above) ran before DestroyTemplate, so the archetype still held refs then. // This pass lets the allocator reclaim pages sooner after the 4 GB drop. CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); #if !UE_BUILD_SHIPPING - FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSharedFragReset")).LogDelta(SnapSwitchStart); + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSecondGC")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + + // Hint the allocator to return freed pages to the OS. App-level frees without + // Trim often keep pages in the process's working set, masking whether the + // earlier steps actually released memory. + FMemory::Trim(); + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapAfterTrim = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterMemTrim")); + SnapAfterTrim.LogDelta(SnapPrev); + // Cumulative drop vs the start of the switch, for quick scanning. + SnapAfterTrim.LogDelta(SnapSwitchStart); + + // Diagnostic probe A: MemReport -full. Writes Saved/Profiling/MemReports/ + // .memreport + .memreportgpu with the full allocator / UObject / RHI + // breakdown. One file per switch — diff them to see what's retained. This is + // temporary instrumentation; revert before committing to main. + if (GEngine && GetWorld()) + { + GEngine->Exec(GetWorld(), TEXT("MemReport -full")); + } + + // Diagnostic probe B: poll memory every 500ms for 5s after the switch kicks + // off. Tells us whether Phys drops on its own (allocator is lazy) vs. stays + // flat (genuine retention). The next file's async load will start inflating + // Phys partway through the poll window — read the first 1-2 samples for the + // steady-state cleanup signal. + if (UWorld* World = GetWorld()) + { + struct FPollState + { + FMobiusMemSnapshot Baseline; + FMobiusMemSnapshot Prev; + int32 Count = 0; + FTimerHandle Handle; + }; + TSharedRef State = MakeShared(); + State->Baseline = FMobiusMemSnapshot::Take(TEXT("FileSwitch_PollBaseline")); + State->Prev = State->Baseline; + + TWeakObjectPtr WeakThis(this); + World->GetTimerManager().SetTimer(State->Handle, FTimerDelegate::CreateLambda( + [State, WeakThis]() + { + if (!WeakThis.IsValid()) { return; } + ++State->Count; + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("FileSwitch_Poll[%d]"), State->Count)); + S.LogDelta(State->Baseline); // vs. cleanup-end baseline + S.LogDelta(State->Prev); // vs. previous 500ms sample + State->Prev = S; + + if (State->Count >= 10) + { + if (UMassEntitySpawnSubsystem* Self = WeakThis.Get()) + { + if (UWorld* W = Self->GetWorld()) + { + W->GetTimerManager().ClearTimer(State->Handle); + } + } + } + }), + 0.5f, /*bLoop=*/true); + } #endif LoadPedestrianData(); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h index 316f276c8..d9a6ba3d0 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h @@ -117,7 +117,15 @@ virtual TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(UAg */ UFUNCTION() void UpdateMaxAgentCount(int32 NewMaxAgentCount); - + + /** + * Drop cached-per-file state on file switch: CachedEntityData, ProgressQueue, + * MaxAgentsQueue, LoadingTaskQueue. Called from MassEntitySpawnSubsystem at the + * start of CreatePedestrianTemplateData so prior file residue isn't held through + * the next load. + */ + void ClearPerFileState(); + protected: /** * Check File Path Exists diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp index 3784b1f56..b50123f28 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/Private/Tests/AgentDataMemoryTest.cpp @@ -15,7 +15,7 @@ #include "CoreMinimal.h" #include "Misc/AutomationTest.h" #include "HAL/PlatformMemory.h" -#include "MassAI/SubSystems/MemoryTraceHelper.h" +#include "Util/MemoryTraceHelper.h" // --------------------------------------------------------------------------- // Helpers diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs index 22478676e..967ccf4df 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs @@ -21,6 +21,7 @@ public ProjectMobiusTests(ReadOnlyTargetRules Target) : base(Target) "MassSpawner", "Json", "JsonUtilities", + "MobiusCore", }); } } From 361388d86383c011dcc67dd8236b3975fb8a8feb Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:19:23 +1200 Subject: [PATCH 05/12] Fix UObject leaks during Datasmith and simulation file switches Fixed several significant memory leaks occurring when switching between .udatasmith, .ifc, and .fbx files. These fixes reduce the process-memory delta per switch from ~749 MB to ~303 MB. Core Datasmith & Runtime Fixes Plugged the "Deferred-Purge" Race: Instead of destroying and re-spawning ADatasmithRuntimeActor (which deferred EndPlay and kept ~840 UObjects rooted), we now reuse the actor across loads. Implemented Two-Tick Deferral: Added a two-tick delay after calling Reset() to ensure FSceneImporter::Tick completes its cleanup before the next import starts. A single tick was found to be insufficient due to FTimerManager execution order. Proactive Garbage Collection: Forced a CollectGarbage sweep at the start of the load continuation to prevent the memory footprint from doubling before the engine's natural GC cycle. Actor & Component Cleanup Material & Timer Leaks: Fixed leaks in AFlowCounter (MIDs) and UScreenFacingWorldWidgetComp (Active timers) by explicitly clearing them in EndPlay. Visualizer Optimization: Fixed a monotonic growth bug in HeatmapPixelTextureVisualizer where the index buffer (MeshTriangles) was never emptied. Thread Safety: Switched to TWeakObjectPtr for async mesh builds to prevent crashes or leaks if an actor is destroyed mid-build. Logic & Subsystem Fixes Buffer Drains: Fixed a bug where MeshLoaderRunnable was nulled without being deleted, causing massive leaks in ProceduralMeshComponent buffers. Subsystem Resets: Added ResetForFileSwitch hooks to the Statistics and Widget subsystems to clear agent data and unbind delegates, preventing dangling references to destroyed widgets. Verification Results: Live Refs: Confirmed via FReferenceChainSearch (dropped from 838 to 1). Stability: Confirmed flat object counts for BoxComponent and DeformableQuadComponent across multiple switches. Memory: Residual growth in obj list is now confirmed as PendingKill assets awaiting engine GC, not active leaks. Will assess current blueprint code for any missed object/delegate cleanups AI Usage: Claude was used to identify potential leak sights, reading debug profiling logs and assist with patches a notable patch was moving from an object to a TWeakObjectPtr in HeatmapPixelTextureVisualizer.cpp --- .../MobiusCore/Private/Actors/FlowCounter.cpp | 5 + .../Actors/HeatmapPixelTextureVisualizer.cpp | 50 +++--- .../BuildingGenerator/RuntimeMeshBuilder.cpp | 151 +++++++++++------- .../Private/Subsystems/StatisticSubsystem.cpp | 19 +++ .../BuildingGenerator/RuntimeMeshBuilder.h | 16 ++ .../Public/Subsystems/StatisticSubsystem.h | 8 + .../Private/Core/MobiusWidgetSubsystem.cpp | 12 ++ .../Private/UI/InWorld/AgentInfoDisplay.cpp | 13 ++ .../InWorld/ScreenFacingWorldWidgetComp.cpp | 11 ++ .../Public/Core/MobiusWidgetSubsystem.h | 37 +++-- .../Public/UI/InWorld/AgentInfoDisplay.h | 1 + .../UI/InWorld/ScreenFacingWorldWidgetComp.h | 2 + .../SubSystems/MassEntitySpawnSubsystem.cpp | 27 ++++ .../ProjectMobius/ProjectMobius.Build.cs | 3 +- 14 files changed, 265 insertions(+), 90 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp index a121452cb..7f513b70e 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp @@ -477,6 +477,11 @@ void AFlowCounter::EndPlay(const EEndPlayReason::Type EndPlayReason) PreviousTrackedAgentLocations.Empty(); } + // Drop the dynamic material instance reference. UE actor destruction reclaims + // declared subobjects but not MIDs created via CreateDynamicMaterialInstance, + // which otherwise remain rooted via this UPROPERTY across simulation switches. + CounterBarrierVisualMID = nullptr; + #if !UE_BUILD_SHIPPING FMobiusMemSnapshot::Take(FString::Printf(TEXT("FC_EndPlay_End[%s]"), *GetName())).LogDelta(SnapFcEndStart); #endif diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp index 79717d8a5..aea4d54b4 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp @@ -760,13 +760,19 @@ void AHeatmapPixelTextureVisualizer::BuildGridMeshPlane(const FVector2D& MeshSiz MeshUVs.Empty(); MeshTriangles.Empty(); - // Because grid mesh building is a heavy task we need to do it off the game thread - AsyncTask(ENamedThreads::AnyThread, [this, NumTriangles, MeshSize, bIsStandardHeatmap]() + // Because grid mesh building is a heavy task we need to do it off the game thread. + // Capture via TWeakObjectPtr so a destroyed actor (e.g. file switch mid-build) + // doesn't leave the worker dereferencing freed members. + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::AnyThread, [WeakThis, NumTriangles, MeshSize, bIsStandardHeatmap]() { - UKismetProceduralMeshLibrary::CreateGridMeshWelded(NumTriangles.X, NumTriangles.Y, MeshTriangles, MeshVertices, MeshUVs, 25); + AHeatmapPixelTextureVisualizer* Self = WeakThis.Get(); + if (!Self || !IsValid(Self->RuntimeHeatmapMeshComponent)) return; + + UKismetProceduralMeshLibrary::CreateGridMeshWelded(NumTriangles.X, NumTriangles.Y, Self->MeshTriangles, Self->MeshVertices, Self->MeshUVs, 25); // Update the mesh - RuntimeHeatmapMeshComponent->CreateMeshSection(0, MeshVertices, MeshTriangles, TArray(), MeshUVs, TArray(), TArray(), false); + Self->RuntimeHeatmapMeshComponent->CreateMeshSection(0, Self->MeshVertices, Self->MeshTriangles, TArray(), Self->MeshUVs, TArray(), TArray(), false); }); } @@ -1037,37 +1043,41 @@ void AHeatmapPixelTextureVisualizer::GenerateMeshVerticesUVsAndTriangles(const F return; } - if(MeshVertices.Num() > 0 || MeshUVs.Num() > 0) + if(MeshVertices.Num() > 0 || MeshUVs.Num() > 0 || MeshTriangles.Num() > 0) { UE_LOG(LogTemp, Warning, TEXT("GenerateMeshVerticesUVsAndTriangles: Clearing pre-existing mesh data")); MeshVertices.Empty(); MeshUVs.Empty(); + MeshTriangles.Empty(); } - Async(EAsyncExecution::ThreadPool, [this, NumTriangles, CellSize, MeshBuilder]() + // Capture via TWeakObjectPtr so a destroyed actor doesn't leave the + // worker / GT continuation dereferencing freed members during a file switch. + TWeakObjectPtr WeakThis(this); + Async(EAsyncExecution::ThreadPool, [WeakThis, NumTriangles, CellSize, MeshBuilder]() { - // Input validation performed before dispatching this task - + AHeatmapPixelTextureVisualizer* Self = WeakThis.Get(); + if (!Self) return; + // Generate the quads to restrict the triangle generation to areas needed - TArray Quads = FindAllQuads(MeshBuilder); - + TArray Quads = Self->FindAllQuads(MeshBuilder); + // Generate the vertices and UVs - CreateMeshVertexsAndUVs(NumTriangles, CellSize); - + Self->CreateMeshVertexsAndUVs(NumTriangles, CellSize); + // Generate the Triangles for this square - GenerateMeshTrianglesInQuadMapping(NumTriangles, Quads); - - + Self->GenerateMeshTrianglesInQuadMapping(NumTriangles, Quads); }, - [this] + [WeakThis] { - AsyncTask(ENamedThreads::GameThread, [this]() + AsyncTask(ENamedThreads::GameThread, [WeakThis]() { - + AHeatmapPixelTextureVisualizer* Self = WeakThis.Get(); + if (!Self || !IsValid(Self->RuntimeHeatmapMeshComponent)) return; + // Generate the mesh section - RuntimeHeatmapMeshComponent->CreateMeshSection_LinearColor(0, MeshVertices, MeshTriangles, TArray(), MeshUVs, TArray(), TArray(), false); - + Self->RuntimeHeatmapMeshComponent->CreateMeshSection_LinearColor(0, Self->MeshVertices, Self->MeshTriangles, TArray(), Self->MeshUVs, TArray(), TArray(), false); }); }); diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp index c969ea40c..832f8e1be 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp @@ -388,16 +388,6 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // Make sure no residual data of the procedural mesh comp exists ResetMeshCollisionAndPhysics(); - // Reuse the anchor across loads. Destroying + respawning defers the old - // SceneImporter's teardown past the next LoadFile, so its AssetElementMapping - // still holds TObjectPtrs to the prior scene's RuntimeMesh/BodySetup objects - // when GC runs — 837+ transient UObjects survive every switch. Reset() clears - // those maps synchronously before LoadFile repopulates them. - if (RuntimeDatasmithAnchor) - { - RuntimeDatasmithAnchor->Reset(); - } - // Double-flush queues in case async work enqueued new items during teardown PendingCollisionEnable.Reset(); PendingDatasmithMeshes.Reset(); @@ -408,6 +398,67 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // Remove any flow counters, as the mesh is changing FlowCounterSpawnerComponent->RemoveAllFlowCounters(); + // Cancel any previous deferred continuation so rapid switches don't stack. + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().ClearTimer(DeferredLoadTimerHandle); + } + + // If we have a live DatasmithRuntime anchor from a prior load, queue its + // SceneImporter to purge AssetDataList / AssetElementMapping on the next + // tick. The plugin's Reset() only sets TasksToComplete=ResetScene; the + // actual DeleteData() + AssetRegistry::CleanUp() + internal CollectGarbage + // run inside FSceneImporter::Tick. We must not call LoadFile (or spin up + // the fbx pipeline) until that tick completes, otherwise StartImport + // overwrites TasksToComplete and the prior scene's ~837 RuntimeMesh + + // ~840 BodySetup UObjects survive. Applies regardless of the new file's + // extension — switching datasmith -> fbx also needs the anchor purged. + if (RuntimeDatasmithAnchor) + { + RuntimeDatasmithAnchor->Reset(); + + if (UWorld* World = GetWorld()) + { + // Two-tick defer: within a single frame FTimerManager fires before + // the actor's Tick, so a single next-tick delay lets our LoadFile + // run (and overwrite TasksToComplete=CollectSceneData) before the + // SceneImporter's ResetScene branch ever executes. Chaining two + // next-tick timers guarantees at least one full ADatasmithRuntime + // Tick runs between Reset() and LoadFile, so DeleteData() + + // AssetRegistry::CleanUp() + the plugin's internal CollectGarbage + // finish before the next scene populates AssetDataList. + TWeakObjectPtr WeakThis(this); + World->GetTimerManager().SetTimerForNextTick( + FTimerDelegate::CreateLambda([WeakThis]() + { + ARuntimeMeshBuilder* Self = WeakThis.Get(); + if (!Self) return; + UWorld* InnerWorld = Self->GetWorld(); + if (!InnerWorld) return; + InnerWorld->GetTimerManager().SetTimerForNextTick( + FTimerDelegate::CreateLambda([WeakThis]() + { + if (ARuntimeMeshBuilder* Inner = WeakThis.Get()) + { + Inner->ContinueLoadAfterPurge(); + } + })); + })); + return; + } + } + + // No anchor (first load, or fbx->fbx) — nothing to purge, run immediately. + ContinueLoadAfterPurge(); +} + +void ARuntimeMeshBuilder::ContinueLoadAfterPurge() +{ + // The 2-tick defer let the plugin's ResetScene tick unreference all prior- + // scene RuntimeMesh/BodySetup objects, but they're still PendingKill in the + // UObject array. Sweep them now so the next LoadFile doesn't double the + // resident set before engine GC runs. + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS, true); // As we are now able to use Datasmith assets we need to check if the file is a .udatasmith file if(MeshFileName.Contains(".udatasmith") || MeshFileName.Contains(".ifc")) { @@ -497,13 +548,6 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() }); }); - - - - - // wait for the scene to be loaded for now delay for 5 seconds - //FTimerHandle TimerHandle; - //GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &ARuntimeMeshBuilder::TestDatasmithMaterialSetup, 15.0f, false); } else { @@ -513,32 +557,15 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() //ImportOptions.TessellationOptions.bUseCADKernel = true; RuntimeDatasmithAnchor->ImportOptions = ImportOptions; - - // // construct new object UDatasmithSceneElement - // UDatasmithSceneElement* DatasmithSceneElement = NewObject(); - // - // UDatasmithSceneElement* ConstructedScene = DatasmithSceneElement->ConstructDatasmithSceneFromFile(MeshFileName); - // - // auto Imported = ConstructedScene->ImportScene("/Game/"); - // - // auto CreatedScene = FDatasmithSceneFactory::CreateScene(*Imported.Scene.GetName()); - // - // RuntimeDatasmithAnchor->SetScene(CreatedScene); - // RuntimeDatasmithAnchor->ApplyNewScene(); - // RuntimeDatasmithAnchor->MarkComponentsRenderStateDirty(); } - - } - // not a datasmith file so we can load the mesh as normal + // not a datasmith file so we can load the mesh as normal. Anchor (if any) + // has had its prior scene purged by the ResetScene tick and stays idle. else { - //GetMeshDataFromFile(FRotator(0.0f, 0.0f, 90.0f)); AsyncUpdateMesh(MeshFileName); bIsResettingForNewLoad = false; } - - } void ARuntimeMeshBuilder::AsyncUpdateMesh(const FString PathToMesh) @@ -586,6 +613,15 @@ void ARuntimeMeshBuilder::AsyncUpdateMesh(const FString PathToMesh) ExistingRunnable->Stop(); ExistingRunnable->Exit(); + // Free the CPU-side mesh buffers immediately. Without this, the runnable + // sits in the deferred GT delete lambda holding hundreds of MB until the + // game thread services the queue. + ExistingRunnable->Vertices.Empty(); + ExistingRunnable->Faces.Empty(); + ExistingRunnable->Normals.Empty(); + ExistingRunnable->UV.Empty(); + ExistingRunnable->Tangents.Empty(); + // Capture by value — ExistingRunnable is a stack-local pointer and goes // out of scope when this function returns, before the GT task runs. AsyncTask(ENamedThreads::GameThread, [ExistingRunnable] @@ -644,11 +680,30 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() TArray(), true/*set to true so we can use collisions - at a small cost of performance*/); - // The loader is no longer needed so we can stop the thread - AsyncAssimpLoader->MeshLoaderRunnable->Stop(); + // The loader is no longer needed. Properly stop, drop the CPU-side mesh + // buffers, and delete the runnable. The previous code path nulled + // MeshLoaderRunnable without deleting it, leaking the runnable plus + // hundreds of MB of Vertices/Faces/Normals/UV/Tangents per load. + if (auto* ExistingRunnable = AsyncAssimpLoader->MeshLoaderRunnable) + { + AsyncAssimpLoader->MeshLoaderRunnable = nullptr; + + ExistingRunnable->Stop(); + ExistingRunnable->Exit(); - // nullptr the runnable to free up memory - AsyncAssimpLoader->MeshLoaderRunnable = nullptr; + // CreateMeshSection_LinearColor above copies the arrays into the + // procedural mesh; the runnable's copies are dead weight from here on. + ExistingRunnable->Vertices.Empty(); + ExistingRunnable->Faces.Empty(); + ExistingRunnable->Normals.Empty(); + ExistingRunnable->UV.Empty(); + ExistingRunnable->Tangents.Empty(); + + AsyncTask(ENamedThreads::GameThread, [ExistingRunnable] + { + delete ExistingRunnable; + }); + } // if the material property is set then we want to apply our material to the mesh if(MobiusMaterialInstanceDynamic != nullptr) @@ -666,22 +721,6 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() // Broadcast that the mesh has been built OnMeshBuilt.Broadcast(HeatmapOrigin, MobiusProceduralMeshComponent->Bounds.BoxExtent); - // check if runnable is null and if not then delete it - if (auto* ExistingRunnable = AsyncAssimpLoader->MeshLoaderRunnable) - { - AsyncAssimpLoader->MeshLoaderRunnable = nullptr; - - // Stop the existing runnable - ExistingRunnable->Stop(); - ExistingRunnable->Exit(); - - // Capture by value — ExistingRunnable is a stack-local pointer and goes - // out of scope when this function returns, before the GT task runs. - AsyncTask(ENamedThreads::GameThread, [ExistingRunnable] - { - delete ExistingRunnable; - }); - } EndLoadingWidget(); } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp index 5265f7f39..e26248f65 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp @@ -218,6 +218,25 @@ void UStatisticSubsystem::ResetFlowCounters() #endif } +void UStatisticSubsystem::ResetForFileSwitch() +{ +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapStart = FMobiusMemSnapshot::Take( + FString::Printf(TEXT("StatSub_ResetForFileSwitch_Start[agents=%d]"), + PedestrianAgentData.Num())); +#endif + + PedestrianAgentData.Empty(); + SelectedAgentData = FAgentMeshViewer(); + HoveredAgentData = FAgentMeshViewer(); + + OnSelectedAgentInfoChanged.Broadcast(); + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("StatSub_ResetForFileSwitch_End")).LogDelta(SnapStart); +#endif +} + void UStatisticSubsystem::AddRemoveActiveFlowCounter(AFlowCounter* FlowCounter, bool bAddToActiveCounters) { if (bAddToActiveCounters && FlowCounter) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h index 42039eeef..a5eeed4f1 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h @@ -131,6 +131,22 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac UFUNCTION(BlueprintCallable, Category = "MeshGenerator|UpdateMethods") void UpdateMeshFileName(); +private: + /** + * Continuation of UpdateMeshFileName that runs after the DatasmithRuntime + * SceneImporter has processed its ResetScene task (one game-thread tick + * after Reset()). Dispatches to the .udatasmith/.ifc or fbx loader using + * the member MeshFileName. Called directly on first load, via + * DeferredLoadTimerHandle otherwise. + */ + void ContinueLoadAfterPurge(); + + /** Pending one-shot timer for the deferred continuation. Cleared/replaced + * on every new UpdateMeshFileName so rapid switches don't stack callbacks. */ + FTimerHandle DeferredLoadTimerHandle; + +public: + /** * Function to update the Mesh Data via the Async Assimp * diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Subsystems/StatisticSubsystem.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Subsystems/StatisticSubsystem.h index 589dbc4fa..77aa71a9a 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Subsystems/StatisticSubsystem.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Subsystems/StatisticSubsystem.h @@ -141,6 +141,14 @@ class MOBIUSCORE_API UStatisticSubsystem : public UWorldSubsystem UFUNCTION(BlueprintCallable) void ResetFlowCounters(); + /** + * Drop per-file agent data (PedestrianAgentData / SelectedAgentData / + * HoveredAgentData) so widgets observing OnSelectedAgentInfoChanged see + * empty state on the next switch instead of stale entries from the + * previous simulation. Hooked into the FileSwitch teardown sequence. + */ + void ResetForFileSwitch(); + /** */ UFUNCTION(BlueprintCallable) void AddRemoveActiveFlowCounter(AFlowCounter* FlowCounter, bool bAddToActiveCounters); diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/Core/MobiusWidgetSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/Core/MobiusWidgetSubsystem.cpp index 5974a7ddc..a0435da13 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/Core/MobiusWidgetSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/Core/MobiusWidgetSubsystem.cpp @@ -161,6 +161,18 @@ void UMobiusWidgetSubsystem::Deinitialize() Super::Deinitialize(); } +void UMobiusWidgetSubsystem::ResetForFileSwitch() +{ + // ErrorWidget and LoadingNotifyWidget are persistent infrastructure widgets + // the subsystem owns for the lifetime of the world. They are bound to live + // LoadingSubsystem delegates and the next file load broadcasts to them + // immediately, so nulling them mid-switch crashes the next SetLoadingText. + // They are NOT the per-simulation MID/shader-map root we suspected. + // This hook is intentionally a no-op for now and kept as the call site for + // future per-switch widget resets (e.g. floor-stats panels) without having + // to re-thread the subsystem reset through MassEntitySpawnSubsystem. +} + void UMobiusWidgetSubsystem::Tick(float DeltaTime) { Super::Tick(DeltaTime); diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp index f5031ad35..236fd3ca9 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp @@ -60,6 +60,19 @@ void UAgentInfoDisplay::ReleaseSlateResources(bool bReleaseChildren) FollowIndicatorWidget.Reset(); } +void UAgentInfoDisplay::BeginDestroy() +{ + if (UWorld* World = GetWorld()) + { + if (UStatisticSubsystem* StatSub = World->GetSubsystem()) + { + StatSub->OnSelectedAgentInfoChanged.RemoveAll(this); + } + } + + Super::BeginDestroy(); +} + TSharedRef UAgentInfoDisplay::RebuildWidget() { // Create the children diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/ScreenFacingWorldWidgetComp.cpp b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/ScreenFacingWorldWidgetComp.cpp index 5fa926e49..80134b712 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/ScreenFacingWorldWidgetComp.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/ScreenFacingWorldWidgetComp.cpp @@ -57,6 +57,17 @@ void UScreenFacingWorldWidgetComp::BeginPlay() } +void UScreenFacingWorldWidgetComp::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().ClearTimer(UpdateWidgetRotationTimerHandle); + } + + Super::EndPlay(EndPlayReason); +} + + // Called every frame void UScreenFacingWorldWidgetComp::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/Core/MobiusWidgetSubsystem.h b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/Core/MobiusWidgetSubsystem.h index 2cb77f508..97210ca67 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/Core/MobiusWidgetSubsystem.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/Core/MobiusWidgetSubsystem.h @@ -11,14 +11,14 @@ * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. + * all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ @@ -39,7 +39,7 @@ enum class EMobiusLogWindowCommand : uint8; DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnLogWindowClosedBP); /** - * + * */ UCLASS() class MOBIUSWIDGETS_API UMobiusWidgetSubsystem : public UTickableWorldSubsystem @@ -147,7 +147,7 @@ class MOBIUSWIDGETS_API UMobiusWidgetSubsystem : public UTickableWorldSubsystem */ UFUNCTION(BlueprintCallable, Category = "LoadingNotifyWidget") UImprovedLoadingNotifyWidget* GetLoadingWidget() const; - + /** * Update Load percent value used for binding with external delegates * @@ -174,6 +174,15 @@ class MOBIUSWIDGETS_API UMobiusWidgetSubsystem : public UTickableWorldSubsystem UFUNCTION() void UpdateLoadingInfiniteWidget(bool bIsLoading, FString NewLoadingText); + /** + * Drop transient per-simulation widget references (ErrorWidget, + * LoadingNotifyWidget) so they don't root the prior simulation's widget + * tree across a file switch. The subsystem itself is a world subsystem + * and survives the switch; these UPROPERTYs would otherwise stay alive + * with all the MIDs / shader maps they reference. + */ + void ResetForFileSwitch(); + private: /** * Handle move/resize activity updates from moveable windows. @@ -229,11 +238,13 @@ class MOBIUSWIDGETS_API UMobiusWidgetSubsystem : public UTickableWorldSubsystem * @return Center Position of the Widget */ FVector2D GetCenterPosForWidgetPanel(class UPanelWidget* WidgetPanel); - + void LogWindowIsClosing(); + + #pragma endregion METHODS - + #pragma region PROPERTIES public: // Error Widget @@ -243,10 +254,10 @@ class MOBIUSWIDGETS_API UMobiusWidgetSubsystem : public UTickableWorldSubsystem // Loading Notify Widget UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "LoadingNotifyWidget") UImprovedLoadingNotifyWidget* LoadingNotifyWidget; - + // Log Window Closed Delegate FOnLogWindowClosed OnLogWindowClosedNative; - + UPROPERTY(BlueprintAssignable, Category = "Logging") FOnLogWindowClosedBP OnLogWindowClosedBP; #pragma endregion PROPERTIES diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h index 864126863..4bc56e42f 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h @@ -61,6 +61,7 @@ class MOBIUSWIDGETS_API UAgentInfoDisplay : public UWidget virtual void SynchronizeProperties() override; virtual void ReleaseSlateResources(bool bReleaseChildren) override; virtual TSharedRef RebuildWidget() override; + virtual void BeginDestroy() override; TSharedPtr HoverWidget; TSharedPtr FollowIndicatorWidget; diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/ScreenFacingWorldWidgetComp.h b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/ScreenFacingWorldWidgetComp.h index a72516bea..a27dc3edb 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/ScreenFacingWorldWidgetComp.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/ScreenFacingWorldWidgetComp.h @@ -20,6 +20,8 @@ class MOBIUSWIDGETS_API UScreenFacingWorldWidgetComp : public UWidgetComponent // Called when the game starts virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + public: // Called every frame virtual void TickComponent(float DeltaTime, ELevelTick TickType, diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index c2f3f113b..9060bddde 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -34,6 +34,8 @@ // Other Subsystems we want to use #include "MassAI/SubSystems/AgentDataSubsystem.h" #include "Subsystems/LoadingSubsystem.h" +#include "Subsystems/StatisticSubsystem.h" +#include "Core/MobiusWidgetSubsystem.h" // GameInstance #include "SkeletalMeshAttributes.h" #include "GameInstances/ProjectMobiusGameInstance.h" @@ -366,6 +368,31 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() } #endif + // Drop per-simulation state held by world subsystems before the GC pass. + // These outlive an individual file load (subsystems are world-scoped and + // PIE world only ends on stop), so without an explicit reset they keep + // the prior simulation's agent data + widget tree (MIDs, shader maps) + // rooted across switches. + if (UWorld* World = GetWorld()) + { + if (UStatisticSubsystem* StatSub = World->GetSubsystem()) + { + StatSub->ResetForFileSwitch(); + } + if (UMobiusWidgetSubsystem* WidgetSub = World->GetSubsystem()) + { + WidgetSub->ResetForFileSwitch(); + } + } + +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterSubsystemReset")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + // Empty out the handles array SpawnedEntityPedestrianHandles.Empty(); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/ProjectMobius.Build.cs b/UnrealFolder/ProjectMobius/Source/ProjectMobius/ProjectMobius.Build.cs index f3ea4a454..939514a74 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/ProjectMobius.Build.cs +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/ProjectMobius.Build.cs @@ -50,7 +50,8 @@ public ProjectMobius(ReadOnlyTargetRules Target) : base(Target) // for JSOn handling and our web sockets "Json", "JsonUtilities", - "MobiusCore", + "MobiusCore", + "MobiusWidgets", "XRBase", }); From 14d51edab1e7f806e836126ac8814df27d1a8797 Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:20:04 +1200 Subject: [PATCH 06/12] Free Datasmith scene resources on EndPlay Add ReleaseDatasmithSceneResources() and call it from EndPlay to flush rendering commands, detach static mesh components, clear physics meshes, release render resources and wait on fences so heavy GPU/CPU buffers owned by DatasmithRuntime are freed. Move MasterTypeCache to a class-level static (and clear it on EndPlay) so UMaterial* entries don't become stale across PIE sessions; clear DatasmithMaterialsMap and destroy the RuntimeDatasmithAnchor. Also include minor header/formatting cleanups. Additionally add/import updated splash/icon assets and PNG variants, and set ProjectDisplayedTitle="Mobius Viewer" in DefaultGame.ini. UAsset LFS blobs updated accordingly. --- ImportedOpenSourceAssets/MobiusIconResize.ico | Bin 0 -> 270398 bytes .../MobiusSplashResize.bmp | 3 + .../MobiusSplashResize.png | 3 + .../ProjectMobius/Config/DefaultGame.ini | 1 + .../ProjectMobius/Content/Splash/EdSplash.bmp | 4 +- .../ProjectMobius/Content/Splash/EdSplash.png | 3 + .../Content/Splash/EdSplash.uasset | 4 +- .../Content/Splash/ProjectMobius.uasset | 4 +- .../ProjectMobius/Content/Splash/Splash.bmp | 4 +- .../ProjectMobius/Content/Splash/Splash.png | 3 + .../Content/Splash/Splash.uasset | 4 +- .../BuildingGenerator/RuntimeMeshBuilder.cpp | 107 +++++++++++++++++- .../BuildingGenerator/RuntimeMeshBuilder.h | 87 ++++++++------ 13 files changed, 180 insertions(+), 47 deletions(-) create mode 100644 ImportedOpenSourceAssets/MobiusIconResize.ico create mode 100644 ImportedOpenSourceAssets/MobiusSplashResize.bmp create mode 100644 ImportedOpenSourceAssets/MobiusSplashResize.png create mode 100644 UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png create mode 100644 UnrealFolder/ProjectMobius/Content/Splash/Splash.png diff --git a/ImportedOpenSourceAssets/MobiusIconResize.ico b/ImportedOpenSourceAssets/MobiusIconResize.ico new file mode 100644 index 0000000000000000000000000000000000000000..c2086e13e1a915c27100620c076d54e78698f75a GIT binary patch literal 270398 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∨fW=&i?o( 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 VdZ6inrU#lHXnLUOfo;AA{ttm|Ud{jj literal 0 HcmV?d00001 diff --git a/ImportedOpenSourceAssets/MobiusSplashResize.bmp b/ImportedOpenSourceAssets/MobiusSplashResize.bmp new file mode 100644 index 000000000..572b5d991 --- /dev/null +++ b/ImportedOpenSourceAssets/MobiusSplashResize.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 +size 2003082 diff --git a/ImportedOpenSourceAssets/MobiusSplashResize.png b/ImportedOpenSourceAssets/MobiusSplashResize.png new file mode 100644 index 000000000..572b5d991 --- /dev/null +++ b/ImportedOpenSourceAssets/MobiusSplashResize.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 +size 2003082 diff --git a/UnrealFolder/ProjectMobius/Config/DefaultGame.ini b/UnrealFolder/ProjectMobius/Config/DefaultGame.ini index ed82f9491..b8fdc9547 100644 --- a/UnrealFolder/ProjectMobius/Config/DefaultGame.ini +++ b/UnrealFolder/ProjectMobius/Config/DefaultGame.ini @@ -10,6 +10,7 @@ ResolutionSizeY=720 FullscreenMode=2 LicensingTerms=/** * MIT License * Copyright (c) 2025 ProjectMobius contributors * Nicholas R. Harding and Peter Thompson * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ bAllowWindowResize=True +ProjectDisplayedTitle=NSLOCTEXT("[/Script/EngineSettings]", "488452E64D1E28DD7244EA8BD87B7820", "Mobius Viewer") [StartupActions] bAddPacks=True diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp index 3a294c780..572b5d991 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp +++ b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5b6548063bc7af484ca06e180436b9e0474fe2fafcc424a5bd4184538babe1d -size 147594 +oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 +size 2003082 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png new file mode 100644 index 000000000..572b5d991 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 +size 2003082 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset index 26e7de48b..b8b9d2478 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset +++ b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68902d5d2c24849d5ec6692220b491b194d238366d680886936ffb568f33464b -size 64684 +oid sha256:1e2b4f84122e6670257fda789954a8a692321ee53ebeddbdaffb0f6a9dcd45bc +size 366275 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset b/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset index fc1719d12..101e7e1be 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset +++ b/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4fdfc7c8633fef7746bc28d709e01f1fe3389c6a6725ff99ba09584d58ccab8 -size 64799 +oid sha256:4d6bb3230544605ff0122f6fafae76581d66d419182484f4b6f14a89aec4b521 +size 64786 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp b/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp index 3a294c780..572b5d991 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp +++ b/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5b6548063bc7af484ca06e180436b9e0474fe2fafcc424a5bd4184538babe1d -size 147594 +oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 +size 2003082 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.png b/UnrealFolder/ProjectMobius/Content/Splash/Splash.png new file mode 100644 index 000000000..572b5d991 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Content/Splash/Splash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 +size 2003082 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset b/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset index 98dafb1e9..ad4dfa84b 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset +++ b/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:443bc8815a5cf29daf489a374b17ab475626c0823f4c3164451d259f2647fef5 -size 64638 +oid sha256:cc5b2772b627fd71890084eb87b8fe95cafac907b3303efffe33a387ff3ec08d +size 366229 diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp index 832f8e1be..18b1adc9a 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp @@ -45,6 +45,10 @@ #include "Engine/Engine.h" #include "HAL/PlatformTime.h" #include "Misc/ScopeExit.h" +#include "Engine/StaticMesh.h" +#include "RenderingThread.h" + +TMap ARuntimeMeshBuilder::MasterTypeCache; // Sets default values ARuntimeMeshBuilder::ARuntimeMeshBuilder() : @@ -146,12 +150,111 @@ void ARuntimeMeshBuilder::BeginPlay() } } +void ARuntimeMeshBuilder::ReleaseDatasmithSceneResources() +{ + if (!RuntimeDatasmithAnchor || RuntimeDatasmithAnchor->IsActorBeingDestroyed()) + { + return; + } + + // Render resources must be released from the game thread with rendering + // commands already flushed, otherwise the render proxy may reference freed + // vertex/index buffers for one more frame. + FlushRenderingCommands(); + + TSet ReleasedMeshes; + + const TSet& DataComps = RuntimeDatasmithAnchor->GetComponents(); + for (UActorComponent* DataComp : DataComps) + { + USceneComponent* SceneComp = Cast(DataComp); + if (!SceneComp) + { + continue; + } + + TArray ChildrenComps; + SceneComp->GetChildrenComponents(true, ChildrenComps); + + for (USceneComponent* ChildComp : ChildrenComps) + { + UStaticMeshComponent* MeshComp = Cast(ChildComp); + if (!MeshComp || MeshComp->IsBeingDestroyed()) + { + continue; + } + + // Drop our custom MID override refs on the component so the MIDs + // can be GC'd. + MeshComp->EmptyOverrideMaterials(); + + UStaticMesh* Mesh = MeshComp->GetStaticMesh(); + if (!Mesh) + { + continue; + } + + // Detach from component first so the component's detach path + // doesn't try to read the render proxy after we free it. + MeshComp->SetStaticMesh(nullptr); + + // Each RuntimeMesh may be referenced by multiple components; only + // release its resources once. + bool bAlreadyReleased = false; + ReleasedMeshes.Add(Mesh, &bAlreadyReleased); + if (bAlreadyReleased) + { + continue; + } + + if (UBodySetup* BS = Mesh->GetBodySetup()) + { + BS->ClearPhysicsMeshes(); + BS->InvalidatePhysicsData(); + } + + Mesh->ReleaseResources(); + } + } + + // Wait for the render thread to finish releasing the resources before + // EndPlay continues and the UObject shells get handed to GC. + for (UStaticMesh* Mesh : ReleasedMeshes) + { + if (Mesh) + { + Mesh->ReleaseResourcesFence.Wait(); + } + } +} + void ARuntimeMeshBuilder::EndPlay(const EEndPlayReason::Type EndPlayReason) { // Clear any pending items PendingCollisionEnable.Reset(); PendingDatasmithMeshes.Reset(); + // Drop our custom MID refs held per-mesh so the MIDs can be GC'd once the + // components detach below. + DatasmithMaterialsMap.Empty(); + + // Free the DatasmithRuntime scene's heavy render/collision data. The + // plugin's static FAssetRegistry::RegistrationMap keeps the UObject shells + // alive across PIE stop, but the ~500MB of vertex/index/collision buffers + // those shells own can be released here. + if (RuntimeDatasmithAnchor && !RuntimeDatasmithAnchor->IsActorBeingDestroyed()) + { + ReleaseDatasmithSceneResources(); + + RuntimeDatasmithAnchor->Destroy(); + RuntimeDatasmithAnchor = nullptr; + } + + // MasterTypeCache holds raw UMaterial* that become stale after PIE stop. + // Empty it so the next PIE session starts with a clean classification cache + // and doesn't dereference freed pointers. + MasterTypeCache.Empty(); + Super::EndPlay(EndPlayReason); } @@ -1477,8 +1580,8 @@ void ARuntimeMeshBuilder::BuildDatasmithMaterialsForMesh(UStaticMeshComponent* M return; } - // Cache to avoid repeated string comparisons on master materials - static TMap MasterTypeCache; + // MasterTypeCache is now a class-level static (see header) so EndPlay can + // empty it; UMaterial* entries become stale across PIE sessions otherwise. FDatasmithMaterials DatasmithMaterials; diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h index a5eeed4f1..4665f1357 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h @@ -11,14 +11,14 @@ * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. + * all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ @@ -39,7 +39,7 @@ class UMaterialInstanceDynamic; class UMaterialInterface; class UMaterialInstanceConstant; class UMaterial; -class UTexture; +class UTexture; class UMobiusCustomLoggerSubsystem; /** Delegates */ @@ -74,7 +74,7 @@ USTRUCT() struct FPendingDatasmithMesh { GENERATED_BODY() - + UPROPERTY() TWeakObjectPtr Mesh; @@ -88,15 +88,15 @@ UCLASS() class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterface, public IProjectMobiusInterface { GENERATED_BODY() - -public: + +public: #pragma region PUBLIC_METHODS // Sets default values for this actor's properties ARuntimeMeshBuilder(); // Called when the game starts or when spawned virtual void BeginPlay() override; - + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; // Called every frame @@ -106,18 +106,18 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** * Function to generate the Mobius Runtime Mesh from the given vertices, triangles, and normals - * + * * @param InVertices The Vertices to generate the mesh from * @param InTriangles The Triangles to generate the mesh from * @param InNormals The Normals to generate the mesh from - * + * */ UFUNCTION(BlueprintCallable, Category = "MeshGenerator|Generation") void GenerateMobiusMesh(TArray InVertices, TArray InTriangles, TArray InNormals); /** * Function to get the Mesh Data via the Assimp Interface - * + * * @param MeshRotationOffset Different Modeling software have different coordinate systems, and may require rotation */ UFUNCTION(BlueprintCallable, Category = "MeshGenerator|Generation") @@ -145,11 +145,28 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac * on every new UpdateMeshFileName so rapid switches don't stack callbacks. */ FTimerHandle DeferredLoadTimerHandle; + /** + * Release the heavy data (vertex/index buffers, collision meshes) owned by + * UObjects the DatasmithRuntime plugin keeps alive via its static + * FAssetRegistry::RegistrationMap. Called from EndPlay so that even though + * the UObject shells survive past PIE stop, their ~500MB of GPU/CPU + * resources are freed. + */ + void ReleaseDatasmithSceneResources(); + + /** + * Cached master-material classification. Class-level static (instead of + * function-local) so EndPlay can clear it — UMaterial* entries become stale + * across PIE sessions and would otherwise dereference freed pointers on the + * next load. + */ + static TMap MasterTypeCache; + public: /** - * Function to update the Mesh Data via the Async Assimp - * + * Function to update the Mesh Data via the Async Assimp + * * @param PathToMesh The Path to the Mesh to load */ UFUNCTION(BlueprintCallable, Category = "MeshGenerator|UpdateMethods") @@ -220,21 +237,21 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** Internal Method to handle Opaque Material creation of datasmith materials */ TArray> CreateOpaqueMaterials(UMaterialInterface* InMaterial); - + /** Internal Method to handle Translucent Material creation of datasmith materials */ TArray> CreateTranslucentMaterials(UMaterialInterface* InMaterial, bool bIsOpaque = false); TArray> CreateRuntimeOpaqueMaterials(UMaterialInterface* InMaterial); - + TArray> CreateRuntimeTranslucentMaterials(UMaterialInterface* InMaterial, bool bIsOpaque = false); - + void EnqueueCollisionEnable(UStaticMeshComponent* Mesh); void ProcessPendingCollisionEnables(float DeltaSeconds); void ProcessPendingDatasmithMeshes(float DeltaSeconds); void BuildDatasmithMaterialsForMesh(UStaticMeshComponent* MeshComp); - + #pragma endregion PRIVATE_METHODS #pragma region PUBLIC_PROPERTIES_AND_COMPONENTS @@ -242,7 +259,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** The Procedural Mesh Component used for generating meshes at runtime or on construction */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "MeshGenerator|Component") class UProceduralMeshComponent* MobiusProceduralMeshComponent; - + /** Flow Counter Spawner - handles spawning flow counters */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "MeshGenerator|Component") class UFlowCounterSpawnerComponent* FlowCounterSpawnerComponent; @@ -275,17 +292,17 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** This bool is used for working out whether this is a datasmith asset or using the procedural mesh component */ UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MeshGenerator|Datasmith") bool bIsDatasmithAsset = false; - - + + /* * Array to store the Procedural Meshes Vertex Colors to Generate - * - These are stored as Linear Colour Structures, length must be the same as the length of vertices array + * - These are stored as Linear Colour Structures, length must be the same as the length of vertices array */ /* - * Array to store the Procedural Meshes Tangents to Generate - * - These are stored as Proc Mesh Tangent Structures, - * length must be the same as the length of vertices array + * Array to store the Procedural Meshes Tangents to Generate + * - These are stored as Proc Mesh Tangent Structures, + * length must be the same as the length of vertices array */ /***/ @@ -293,7 +310,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac TSubclassOf FlowCounterToAutoSpawn = nullptr; protected: - + /** Door meshes we still need to spawn counters for (weak to avoid dangling refs). */ UPROPERTY() TArray> PendingDoorMeshes; @@ -304,7 +321,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** Are we currently processing the pending door queue? */ bool bIsSpawningFlowCounters = false; - + /** Shared material cache used for Datasmith and runtime materials. */ FMaterialCache MaterialCache; @@ -318,7 +335,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** Are we currently in the middle of batched Datasmith material setup? */ bool bDatasmithMaterialSetupInProgress = false; - + private: /** TODO: We eventually want to get the mesh material and apply our materials to it as a mask or material function to it * Material Instance Dynamic to apply to the Procedural Mesh Component after a mesh has been generated and set with @@ -326,7 +343,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac */ UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "MeshGenerator|Material", meta = (AllowPrivateAccess = "true")) TObjectPtr MobiusMaterialInstanceDynamic = nullptr; - + // Components that still need collision turned on UPROPERTY() TArray> PendingCollisionEnable; @@ -334,7 +351,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac // How many components we allow per frame UPROPERTY(EditAnywhere, Category="Collision") int32 MaxCollisionEnablesPerFrame = 10; - + // Are we currently resetting / swapping out the mesh and Datasmith anchor? UPROPERTY(Transient) bool bIsResettingForNewLoad = false; @@ -344,7 +361,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** Access the startup logger subsystem without an extra dependency on the game instance. */ static UMobiusCustomLoggerSubsystem* GetStartupLogger(); - + #pragma endregion PUBLIC_PROPERTIES_AND_COMPONENTS public: #pragma region GETTERS_SETTERS @@ -357,7 +374,7 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac * Get the DatasmithMaterialsMap */ FORCEINLINE TMap, FDatasmithMaterials> GetDatasmithMaterialsMap() const { return DatasmithMaterialsMap; } - + /** Setters */ /** Set the Material Instance for the mesh */ UFUNCTION(BlueprintCallable, Category = "MeshGenerator|Material") From 5b8b737507a25825ef61fe289affe16f1cd321d9 Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:19:31 +1200 Subject: [PATCH 07/12] Unbind GameInstance delegate; update uasset LFS Remove dynamic binding to GameInstance (OnPedestrianVectorFileUpdated) in UMassEntitySpawnSubsystem::Deinitialize by checking GetMobiusGameInstance(GetWorld()) and calling RemoveDynamic before Super::Deinitialize to avoid dangling callbacks. Also update Git LFS OIDs/sizes for BP_MobiusPawn.uasset and BP_ProjectMobiusController.uasset (asset blob changes). --- .../00_UserAndInputs/UserAndController/BP_MobiusPawn.uasset | 4 ++-- .../UserAndController/BP_ProjectMobiusController.uasset | 4 ++-- .../Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_MobiusPawn.uasset b/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_MobiusPawn.uasset index 671d6ac8d..91034df18 100644 --- a/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_MobiusPawn.uasset +++ b/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_MobiusPawn.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26511e07d34b323f11a2b6703df6b80947c728d4f2a49c10a7515f5e3f0abb41 -size 823843 +oid sha256:b319d31c328a43ceb38f795a7e3ae50ef916f035a1b12339f28e1540d6b1a76e +size 821877 diff --git a/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_ProjectMobiusController.uasset b/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_ProjectMobiusController.uasset index c908c7c04..9f0a3656d 100644 --- a/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_ProjectMobiusController.uasset +++ b/UnrealFolder/ProjectMobius/Content/00_UserAndInputs/UserAndController/BP_ProjectMobiusController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9908877af22789722f3deb5e18e01ef4b7aebef9ed14cc393f109733aa6e4472 -size 380045 +oid sha256:6e3d50ef48b5d772383c0f8e63eabf864ef300084b52a7057d86c85b8933bc69 +size 384389 diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index 9060bddde..53133ac36 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -103,7 +103,10 @@ void UMassEntitySpawnSubsystem::Initialize(FSubsystemCollectionBase& Collection) void UMassEntitySpawnSubsystem::Deinitialize() { - // If we have delegates we can unbind them here before super + if (UProjectMobiusGameInstance* GameInst = GetMobiusGameInstance(GetWorld())) + { + GameInst->OnPedestrianVectorFileUpdated.RemoveDynamic(this, &UMassEntitySpawnSubsystem::CreatePedestrianTemplateData); + } Super::Deinitialize(); } From dc099ca9c20f10c7ac421f1718c1829cb40fd0b9 Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:31:09 +1200 Subject: [PATCH 08/12] Cache GameInstance to manage delegate binding Add a TWeakObjectPtr CachedGameInstance to store the game instance when binding OnPedestrianVectorFileChanged. Set the cache in GetScreenshotRequiredSubsystemsAndData when casting succeeds, and use it in EndPlay to RemoveDynamic and clear the cached pointer. This removes the GetWorld()-based lookup and ensures safe unbinding/cleanup of the delegate. --- .../Private/Controller/MobiusController.cpp | 9 ++++----- .../ProjectMobius/Public/Controller/MobiusController.h | 2 ++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/Controller/MobiusController.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/Controller/MobiusController.cpp index 9087279b1..8aef410dc 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/Controller/MobiusController.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/Controller/MobiusController.cpp @@ -93,12 +93,10 @@ void AMobiusController::BeginPlay() void AMobiusController::EndPlay(const EEndPlayReason::Type EndPlayReason) { - if (GetWorld()) + if (CachedGameInstance.IsValid()) { - if (UProjectMobiusGameInstance* MobiusGameInstance = Cast(GetWorld()->GetGameInstance())) - { - MobiusGameInstance->OnPedestrianVectorFileChanged.RemoveDynamic(this, &AMobiusController::UpdatePedestrianVectorFilePath); - } + CachedGameInstance->OnPedestrianVectorFileChanged.RemoveDynamic(this, &AMobiusController::UpdatePedestrianVectorFilePath); + CachedGameInstance = nullptr; } Super::EndPlay(EndPlayReason); @@ -126,6 +124,7 @@ void AMobiusController::GetScreenshotRequiredSubsystemsAndData() // cast to mobius instance if (UProjectMobiusGameInstance* MobiusGameInstance = Cast(GameInst)) { + CachedGameInstance = MobiusGameInstance; // bind the file update delegate MobiusGameInstance->OnPedestrianVectorFileChanged.AddDynamic(this, &AMobiusController::UpdatePedestrianVectorFilePath); } diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/Controller/MobiusController.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/Controller/MobiusController.h index ded436cc9..96be032f8 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/Controller/MobiusController.h +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/Controller/MobiusController.h @@ -119,6 +119,8 @@ class PROJECTMOBIUS_API AMobiusController : public APlayerController UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MobiusController|Properties|CameraSave") FString CameraSavePointsFileName = TEXT("MobiusCamSavePoints.json"); + TWeakObjectPtr CachedGameInstance; + /** Stores pre-existing saves from camera save file into an array that can be used for moving the user */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MobiusController|Properties|CameraSave") TArray LoadedCameraTransforms; From b182b12af174f1a12487cd19f8fe7bebcc134c2b Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:04:11 +1200 Subject: [PATCH 09/12] Update DT_MobiusLogToggleSettings.uasset the toggle text would change from on and off, the feature made sense to me at the time but ultimately is confusing and lead to users turning on and off debug features repeatedly as they were unsure what state the toggle meant when on or off --- .../00_Utility/Data/DT_MobiusLogToggleSettings.uasset | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Content/01_Dev/Widgets/Components/00_Utility/Data/DT_MobiusLogToggleSettings.uasset b/UnrealFolder/ProjectMobius/Content/01_Dev/Widgets/Components/00_Utility/Data/DT_MobiusLogToggleSettings.uasset index 5818292fa..c39e863b2 100644 --- a/UnrealFolder/ProjectMobius/Content/01_Dev/Widgets/Components/00_Utility/Data/DT_MobiusLogToggleSettings.uasset +++ b/UnrealFolder/ProjectMobius/Content/01_Dev/Widgets/Components/00_Utility/Data/DT_MobiusLogToggleSettings.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b156cd3f93b5292556bdbe4e76e06d3fb4cc0f9c710e4f88155767fd2e35eb93 -size 6099 +oid sha256:549edf6e292df38fac7add4c0a36cb75da0f53fb29ec7a6760acdd72061e828d +size 5890 From b304aeb7287db1829c8dfe7e09aed86777a12d7b Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:04:56 +1200 Subject: [PATCH 10/12] Improve thread joining and subsystem safety Replace hard thread kills and manual Exit() calls with proper join/wait semantics to avoid Assimp/HDF5 leaks and race conditions (WaitForCompletion in destructors, remove manual Exit()). Drop stale delegate bindings before stopping runnables and bind completion delegates before starting new runnables to avoid late broadcasts reaching freed/stale state. Reset runnable pointers and data-load flags (bIsDataLoaded) when switching files to prevent using previous-run data. Make async/game-thread interactions safer: use TWeakObjectPtr in ParallelFor->AsyncTask closures for HeatmapSubsystem spawns, and guard CreateHeatmap calls behind weak-pointer validity checks. Fix widget lifecycle for AgentInfoDisplay: move slate resource release/cleanup to ReleaseSlateResources/RebuildWidget, rebind delegates in RebuildWidget, and remove BeginDestroy override. Add null/IsValid checks and early returns in various MassAI processors (heatmap/time-dilation/niagara) to prevent crashes when subsystems or actors are missing. Also minor whitespace/license formatting cleanup. Overall these changes reduce crashes, memory leaks, and race conditions when stopping/starting background runnables and interacting with game-thread subsystems. --- .../Private/AsyncAssimpMeshLoader.cpp | 7 +- .../BuildingGenerator/RuntimeMeshBuilder.cpp | 14 +++- .../ProjectMobiusGameInstance.cpp | 10 +-- .../Private/Subsystems/HeatmapSubsystem.cpp | 10 ++- .../Private/UI/InWorld/AgentInfoDisplay.cpp | 33 +++++---- .../Public/UI/InWorld/AgentInfoDisplay.h | 1 - .../Representation/AgentHeatmapProcessor.cpp | 9 +++ .../NiagaraAgentRepProcessor.cpp | 67 ++++++++++--------- .../MassAI/SubSystems/AgentDataSubsystem.cpp | 23 ++++--- .../SubSystems/MassEntitySpawnSubsystem.cpp | 29 +++++--- 10 files changed, 117 insertions(+), 86 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp index 31ca7b441..1f0b873e4 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp @@ -140,11 +140,14 @@ FAssimpMeshLoaderRunnable::FAssimpMeshLoaderRunnable(const FString InPathToMesh, FAssimpMeshLoaderRunnable::~FAssimpMeshLoaderRunnable() { - // if the thread is still running, stop it + // Block until Run() returns naturally so the stack-allocated Assimp::Importer gets its + // destructor. Kill(true) hard-terminates mid-import and leaks Assimp-internal state, + // which can trip subsequent HDF5 reads that reuse the same worker path. if (Thread != nullptr) { - Thread->Kill(true); + Thread->WaitForCompletion(); delete Thread; + Thread = nullptr; } } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp index 18b1adc9a..353cc76df 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp @@ -712,9 +712,13 @@ void ARuntimeMeshBuilder::AsyncUpdateMesh(const FString PathToMesh) { AsyncAssimpLoader->MeshLoaderRunnable = nullptr; - // Stop the existing runnable + // Drop the broadcast binding first so a late completion from the old runnable + // can't invoke GetTheAsyncMeshData on already-stale state. + ExistingRunnable->OnLoadMeshDataComplete.RemoveAll(this); + + // Signal stop; destructor (via the deferred GT delete) WaitForCompletion-joins + // the thread, which lets UE call Exit() once cleanly after Run() returns. ExistingRunnable->Stop(); - ExistingRunnable->Exit(); // Free the CPU-side mesh buffers immediately. Without this, the runnable // sits in the deferred GT delete lambda holding hundreds of MB until the @@ -791,8 +795,12 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() { AsyncAssimpLoader->MeshLoaderRunnable = nullptr; + // Drop the completion binding before stopping so any late broadcast is a no-op. + ExistingRunnable->OnLoadMeshDataComplete.RemoveAll(this); + + // Destructor WaitForCompletion-joins the thread and UE calls Exit() once cleanly + // after Run() returns — no manual Exit() from the game thread. ExistingRunnable->Stop(); - ExistingRunnable->Exit(); // CreateMeshSection_LinearColor above copies the arrays into the // procedural mesh; the runnable's copies are dead weight from here on. diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/GameInstances/ProjectMobiusGameInstance.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/GameInstances/ProjectMobiusGameInstance.cpp index a2531a6aa..9d75fcb83 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/GameInstances/ProjectMobiusGameInstance.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/GameInstances/ProjectMobiusGameInstance.cpp @@ -178,13 +178,9 @@ void UProjectMobiusGameInstance::SetLogWindowEnabled(bool bEnabled) void UProjectMobiusGameInstance::SetPedestrianDataFilePath(const FString& NewPedestrianDataFilePath) { - // We only need to update and broadcast if the file path has changed - if(PedestrianDataFileName != NewPedestrianDataFilePath) - { - PedestrianDataFilePath = NewPedestrianDataFilePath; - OnPedestrianVectorFileChanged.Broadcast(NewPedestrianDataFilePath); // Broadcast the new pedestrian vector file - OnPedestrianVectorFileUpdated.Broadcast(); - } + PedestrianDataFilePath = NewPedestrianDataFilePath; + OnPedestrianVectorFileChanged.Broadcast(NewPedestrianDataFilePath); + OnPedestrianVectorFileUpdated.Broadcast(); } void UProjectMobiusGameInstance::SetPedestrianDataFileName(const FString& NewPedestrianDataFileName) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp index 0fccbf4bc..8fd663dfc 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/HeatmapSubsystem.cpp @@ -387,15 +387,19 @@ void UHeatmapSubsystem::ProcessHeatmapGeneration() } // Spawn new - ParallelFor(Heights.Num(), [this, XY, &Heights](int32 Index) + TWeakObjectPtr WeakThis(this); + ParallelFor(Heights.Num(), [WeakThis, XY, &Heights](int32 Index) { // (1) Compute the world position off the game thread const FVector Pos(XY.X, XY.Y, Heights[Index]); // (2) Schedule the actual spawn back on the Game Thread - AsyncTask(ENamedThreads::GameThread, [this, Pos, Index]() + AsyncTask(ENamedThreads::GameThread, [WeakThis, Pos, Index]() { - CreateHeatmap(Pos, Index); + if (UHeatmapSubsystem* Self = WeakThis.Get()) + { + Self->CreateHeatmap(Pos, Index); + } }); }); diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp index 236fd3ca9..3030aadbd 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp @@ -12,14 +12,6 @@ UAgentInfoDisplay::UAgentInfoDisplay(): HoverWidgetMeshViewerID(0), SelectedFollowWidgetMeshViewerID(0) { - if (auto World = GetWorld()) - { - if (auto StatSub = World->GetSubsystem()) - { - // Bind delegates to trigger an update when the agent data changes - World->GetSubsystem()->OnSelectedAgentInfoChanged.AddUObject(this, &UAgentInfoDisplay::UpdateAgentInfoMeshData); - } - } } void UAgentInfoDisplay::UpdateAgentInfoMeshData() @@ -53,14 +45,6 @@ void UAgentInfoDisplay::SynchronizeProperties() } void UAgentInfoDisplay::ReleaseSlateResources(bool bReleaseChildren) -{ - Super::ReleaseSlateResources(bReleaseChildren); - - HoverWidget.Reset(); - FollowIndicatorWidget.Reset(); -} - -void UAgentInfoDisplay::BeginDestroy() { if (UWorld* World = GetWorld()) { @@ -70,9 +54,13 @@ void UAgentInfoDisplay::BeginDestroy() } } - Super::BeginDestroy(); + HoverWidget.Reset(); + FollowIndicatorWidget.Reset(); + + Super::ReleaseSlateResources(bReleaseChildren); } + TSharedRef UAgentInfoDisplay::RebuildWidget() { // Create the children @@ -81,7 +69,7 @@ TSharedRef UAgentInfoDisplay::RebuildWidget() FollowIndicatorWidget = SNew(SAgentFollowIndicator, *this); - + // Create the overlay TSharedRef Overlay = SNew(SOverlay) @@ -96,6 +84,15 @@ TSharedRef UAgentInfoDisplay::RebuildWidget() FollowIndicatorWidget.ToSharedRef() ]; + if (UWorld* World = GetWorld()) + { + if (UStatisticSubsystem* StatSub = World->GetSubsystem()) + { + StatSub->OnSelectedAgentInfoChanged.RemoveAll(this); + StatSub->OnSelectedAgentInfoChanged.AddUObject(this, &UAgentInfoDisplay::UpdateAgentInfoMeshData); + } + } + return Overlay; //return FollowIndicatorWidget.ToSharedRef(); } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h index 4bc56e42f..864126863 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Public/UI/InWorld/AgentInfoDisplay.h @@ -61,7 +61,6 @@ class MOBIUSWIDGETS_API UAgentInfoDisplay : public UWidget virtual void SynchronizeProperties() override; virtual void ReleaseSlateResources(bool bReleaseChildren) override; virtual TSharedRef RebuildWidget() override; - virtual void BeginDestroy() override; TSharedPtr HoverWidget; TSharedPtr FollowIndicatorWidget; diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/AgentHeatmapProcessor.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/AgentHeatmapProcessor.cpp index 84a871f5b..b7b3095f6 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/AgentHeatmapProcessor.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/AgentHeatmapProcessor.cpp @@ -105,6 +105,12 @@ void UAgentHeatmapProcessor::Execute(FMassEntityManager& EntityManager, FMassExe } } + if (!HeatmapSubsystem) + { + bRegisteredProperties = false; + return; + } + if (HeatmapSubsystem->GetHeatmapCount() != ActiveHeatmapCount) { ActiveHeatmapCount = HeatmapSubsystem->GetHeatmapCount(); @@ -157,6 +163,7 @@ bool UAgentHeatmapProcessor::EnsureTimeSubsystem(FMassExecutionContext& Context) void UAgentHeatmapProcessor::UpdateTimeStepAndPause() { //TRACE_CPUPROFILER_EVENT_SCOPE(UAgentHeatmapProcessor_UpdateTimeStepAndPause); + if (!TimeDilationSubSystem) return; const float NewTimeStep = TimeDilationSubSystem->CurrentTimeStep; const bool NewPauseState = TimeDilationSubSystem->bIsPaused; @@ -172,6 +179,7 @@ void UAgentHeatmapProcessor::UpdateTimeStepAndPause() void UAgentHeatmapProcessor::UpdateHeatmapInterval() { //TRACE_CPUPROFILER_EVENT_SCOPE(UAgentHeatmapProcessor_UpdateHeatmapInterval); + if (!TimeDilationSubSystem) return; const float CurrentSimTime = TimeDilationSubSystem->GetCurrentSimTime(); if (CurrentSimTime < LastUpdatedCurrentTime) @@ -246,6 +254,7 @@ void UAgentHeatmapProcessor::ProcessChunk(FMassExecutionContext& Context) void UAgentHeatmapProcessor::ApplyHeatmapUpdates() { //TRACE_CPUPROFILER_EVENT_SCOPE(UAgentHeatmapProcessor_ApplyHeatmapUpdates); + if (!HeatmapSubsystem) return; if (!HeatmapLocations.IsEmpty()) { HeatmapSubsystem->BroadcastTotalAgentCount(HeatmapLocations.Num()); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/NiagaraAgentRepProcessor.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/NiagaraAgentRepProcessor.cpp index 58fdef5c9..33d64f53b 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/NiagaraAgentRepProcessor.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/MassProcessor/Representation/NiagaraAgentRepProcessor.cpp @@ -11,14 +11,14 @@ * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. + * all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ @@ -85,20 +85,20 @@ void UNiagaraAgentRepProcessor::ConfigureQueries() // Time Dilation Subsystem ProcessorRequirements.AddSubsystemRequirement(EMassFragmentAccess::ReadOnly); - + } void UNiagaraAgentRepProcessor::Execute(FMassEntityManager& EntityManager, FMassExecutionContext& ExecutionContext) { //TODO: Make flag for this so no constant gets etc - if (!NiagaraAgentRepActor->IsValidLowLevelFast()) + if (!IsValid(NiagaraAgentRepActor)) { - + // Get the agent representation actor //TODO: this works but it could be better NiagaraAgentRepActor = Cast(UGameplayStatics::GetActorOfClass(GetWorld(), ANiagaraAgentRepActor::StaticClass())); } - + if (TimeDilationSubSystem == nullptr || RepresentationSubsystem == nullptr) { // Get the Time Dilation Subsystem @@ -107,14 +107,14 @@ void UNiagaraAgentRepProcessor::Execute(FMassEntityManager& EntityManager, FMass // Get the representation subsystem RepresentationSubsystem = ExecutionContext.GetWorld()->GetSubsystem(); } - + // check we got the subsystems -> if not then we need to return if (TimeDilationSubSystem == nullptr || RepresentationSubsystem == nullptr || NiagaraAgentRepActor == nullptr) { return; } - + //EntityQuery.ParallelForEachEntityChunk(EntityManager, ExecutionContext, ([this](FMassExecutionContext& Context) EntityQuery.ForEachEntityChunk(EntityManager, ExecutionContext, ([this](FMassExecutionContext& Context) { @@ -132,7 +132,7 @@ void UNiagaraAgentRepProcessor::Execute(FMassEntityManager& EntityManager, FMass { // reset the registered properties bool bRegisteredProperties = false; - + // attempt registering the properties again RegisterProperties(Context); } @@ -140,7 +140,7 @@ void UNiagaraAgentRepProcessor::Execute(FMassEntityManager& EntityManager, FMass { // Check we using correct Niagara System based on the current scalability setting CheckAndUpdateNiagaraRenderSpec(Context); - + // We only want to update the pause state if it has changed if (bLastPauseLoop != TimeDilationSubSystem->bIsPaused) { @@ -149,7 +149,7 @@ void UNiagaraAgentRepProcessor::Execute(FMassEntityManager& EntityManager, FMass } ExtractAgentData(Context); } - + })); UNiagaraComponent* NiagaraComp = NiagaraAgentRepActor ? NiagaraAgentRepActor->GetNiagaraComponent() : nullptr; @@ -168,7 +168,7 @@ void UNiagaraAgentRepProcessor::ExtractAgentData(FMassExecutionContext& Context) TConstArrayView EntityMovementFragment = Context.GetFragmentView(); auto Entities = Context.GetEntities(); - + for (int i = 0; i < Entities.Num(); i++) { auto EntityMovement = EntityMovementFragment[i]; @@ -193,7 +193,7 @@ void UNiagaraAgentRepProcessor::ExtractAgentData(FMassExecutionContext& Context) { SetAgentData(EntityInstanceID, EntityMovement, EntityRendering, ElderlyFemaleAdultAgentLocationAndScales, ElderlyFemaleAdultAgentRotations, ElderlyFemaleAnimationStates); } - + } else // entity is an adult { @@ -206,7 +206,7 @@ void UNiagaraAgentRepProcessor::ExtractAgentData(FMassExecutionContext& Context) SetAgentData(EntityInstanceID, EntityMovement, EntityRendering, FemaleAdultAgentLocationAndScales, FemaleAdultAgentRotations, FemaleAnimationStates); } } - } + } } void UNiagaraAgentRepProcessor::SetAgentData(int32 Index, const FEntityMovementFragment EntityMovementFragment, FEntityRenderingFragment& EntityRenderingFragment, TArray& LocationAndScales, TArray& Rotations, TArray& AnimationStates) @@ -280,7 +280,7 @@ void UNiagaraAgentRepProcessor::RegisterProperties(FMassExecutionContext& Contex // Get the elderly female locations and scales ElderlyFemaleAdultAgentLocationAndScales = AgentNiagaraRepSharedFrag.ElderlyFemaleAdultAgentLocationAndScales; - + // Get the elderly female rotations ElderlyFemaleAdultAgentRotations = AgentNiagaraRepSharedFrag.ElderlyFemaleAdultAgentRotations; @@ -309,7 +309,7 @@ void UNiagaraAgentRepProcessor::PauseResumeAnimations(bool bPause) const { return; } - + // Set the pause state in the Niagara component NiagaraAgentRepActor->GetNiagaraComponent()->SetVariableFloat(TEXT("PauseResumeAnimations"), bPause ? 0.0f : 1.0f); } @@ -339,7 +339,7 @@ bool UNiagaraAgentRepProcessor::CheckAgentCountArraySize(const FNiagaraStatsFrag // if any of the checks fail then we need to return false return false; } - + return true; } @@ -349,7 +349,7 @@ bool UNiagaraAgentRepProcessor::CheckAgentArraySize(int32 Index, int32 ArraySize { return NumberOfAgentsArray[Index] == ArraySize; } - + // if the index is not valid then we need to return false return false; } @@ -363,19 +363,19 @@ void UNiagaraAgentRepProcessor::SetNiagaraAgentData(UNiagaraComponent* NiagaraCo FName Location = *(BaseName + TEXT("LocationAndScale")); FName Rotation = *(BaseName + TEXT("QuatRotations")); FName AnimationState = *(BaseName + TEXT("AnimationStates")); - - + + UNiagaraDataInterfaceArrayFunctionLibrary::SetNiagaraArrayVector4(NiagaraComp, Location, Locations); UNiagaraDataInterfaceArrayFunctionLibrary::SetNiagaraArrayQuat(NiagaraComp, Rotation, Rotations); UNiagaraDataInterfaceArrayFunctionLibrary::SetNiagaraArrayInt32(NiagaraComp, AnimationState, AnimationStates); } -//TODO: FIX THIS - when going low spec to high the animations remain paused and only resume when we pause and unpause +//TODO: FIX THIS - when going low spec to high the animations remain paused and only resume when we pause and unpause void UNiagaraAgentRepProcessor::CheckAndUpdateNiagaraRenderSpec(FMassExecutionContext& Context) { AgentNiagaraRepSharedFrag = Context.GetMutableSharedFragment(); auto& AgentNiagaraStatsSharedFrag = Context.GetMutableSharedFragment(); - + // if RepresentationSubsystem is null then we need to return if (RepresentationSubsystem == nullptr) { @@ -397,9 +397,14 @@ void UNiagaraAgentRepProcessor::CheckAndUpdateNiagaraRenderSpec(FMassExecutionCo AgentNiagaraStatsSharedFrag.bUseLowSpecAgentRenderEffect = RepresentationSubsystem->IsCurrentPedestrianAvatarTypeLowSpec(); // deactivate and destroy instance + if (!IsValid(AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor.Get()) || + AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor->GetNiagaraComponent() == nullptr) + { + return; + } AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor->GetNiagaraComponent()->DeactivateImmediate(); AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor->GetNiagaraComponent()->DestroyInstanceNotComponent(); - + // Create the new system UNiagaraSystem* NiagaraSystem = RepresentationSubsystem->LoadNiagaraAgentSystem(AgentNiagaraStatsSharedFrag.bUseLowSpecAgentRenderEffect); @@ -421,17 +426,17 @@ void UNiagaraAgentRepProcessor::CheckAndUpdateNiagaraRenderSpec(FMassExecutionCo // Set the Niagara System NiagaraAgentRepActor->GetNiagaraComponent()->SetAsset(NiagaraSystem); - + // Set the shared actor component in the shared fragment AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor = NiagaraAgentRepActor; - + // once created we need to pass in the shared niagara data before we activate it AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor->GetNiagaraComponent()->ClearSimCache(); // get the niagara variables for number of agents - + // Set the number of agents in the system AgentNiagaraStatsSharedFrag.NiagaraRepresentationActor->GetNiagaraComponent()->SetVariableInt(TEXT("MaleAdultAgentNumber"), AgentNiagaraStatsSharedFrag.NumberOfMaleAdults); diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp index 97cb5fa89..2d632b0d2 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/AgentDataSubsystem.cpp @@ -143,7 +143,8 @@ void UAgentDataSubsystem::Deinitialize() if (JsonDataRunnable) { JsonDataRunnable->Stop(); - JsonDataRunnable->Exit(); + // Do not call Exit() manually — the destructor calls WaitForCompletion() then + // UE calls Exit() once cleanly after Run() returns. JsonDataRunnable.Reset(); } Super::Deinitialize(); @@ -312,6 +313,10 @@ void UAgentDataSubsystem::UpdateMaxAgentCount(int32 NewMaxAgentCount) void UAgentDataSubsystem::ClearPerFileState() { + // Prevent a stale completion flag from the previous run firing BuildPedestrianMovementFragmentData + // with the new (not-yet-loaded) runnable's empty data on the next Tick. + bIsDataLoaded = false; + // CachedEntityData holds the previous file's FHdf5EntityData array. It was // only ever overwritten when the next file's BuildFrag ran, so between // switches the prior payload stayed live. Drop it now. @@ -1733,17 +1738,18 @@ void FProcessSimulationDataRunnable::Exit() // CloseFile and they're typically 10-30MB per file. Requires exposing // the HDF5 C API header through the plugin's public include path first. } - Hdf5Data.Entities.Empty(); - Hdf5Data.Entities.Shrink(); + // Hdf5Data.Entities, AgentMovementInfoData.SimulationData and NumOfAgentsPerTimeStep + // are consumed by the game thread in BuildPedestrianMovementFragmentData after the + // OnLoadSimulationDataComplete broadcast. UE calls Exit() on the worker thread right + // after Run() returns, which can race ahead of a GT stall (e.g. FBX mesh build on + // CreateMeshSection_LinearColor) — freeing them here would null the shared fragment + // and leave PedestrianInitializeMOP stuck on "CurrentTimeStep not valid". The TUniquePtr + // destructor in AgentDataRunnableCleanup releases everything naturally on the next load. Hdf5Data.Samples.Empty(); Hdf5Data.Samples.Shrink(); Hdf5Data.Meta = FHdf5SimulationMetadata(); SimulationFileType = ESimulationFileType::ESFT_Unknown; - // Drop the TSharedPtr — frees the TMap and all TArrays it owns. - // If SimulationData was already MoveTemp'd to a FSharedStruct, this is a no-op (ptr is null). - AgentMovementInfoData.SimulationData.Reset(); - AgentDataArray.Empty(); AgentDataArray.Shrink(); @@ -1753,9 +1759,6 @@ void FProcessSimulationDataRunnable::Exit() StepVectors.Empty(); StepVectors.Shrink(); - NumOfAgentsPerTimeStep.Empty(); - NumOfAgentsPerTimeStep.Shrink(); - bReadyToDelete = true; // Set the flag to true to indicate that the runnable is ready to be deleted #if !UE_BUILD_SHIPPING diff --git a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp index 53133ac36..83baecd8c 100644 --- a/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Private/MassAI/SubSystems/MassEntitySpawnSubsystem.cpp @@ -250,21 +250,26 @@ void UMassEntitySpawnSubsystem::AgentDataRunnableCleanup(TUniquePtrStop(); - - // 2) Join/Exit on calling thread (don’t bounce to GT). Ensure the runnable sets a “finished” flag. - ToKill->Exit(); - - // 3) Now it’s safe to unbind dynamic delegates on the subsystem (they’re not being used by the worker anymore) + // 1) Remove delegates first — no stale broadcast can reach us even if thread finishes during stop/join if (auto* LS = GetWorld()->GetSubsystem()) { AgentDataSubsystem->OnLoadSimulationDataProgress.RemoveDynamic(LS, &ULoadingSubsystem::BroadcastNewLoadPercent); } AgentDataSubsystem->OnLoadSimulationDataComplete.RemoveDynamic(this, &UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData); //AgentDataSubsystem->OnMaxAgentCount.RemoveDynamic(AgentDataSubsystem, &UAgentDataSubsystem::UpdateMaxAgentCount); - // 4) Delete - ToKill.Reset(); + // 2) Signal the thread to stop + ToKill->Stop(); + + // 3) Join via destructor — WaitForCompletion() is called inside ~FProcessSimulationDataRunnable, + // then UE calls Exit() once cleanly after Run() returns. Do NOT call Exit() manually here; + // that races with the background thread still accessing AgentDataArray / Hdf5Data. + ToKill.Reset(); + + // 4) Clear any stale completion flag now the thread is fully joined + if (AgentDataSubsystem) + { + AgentDataSubsystem->bIsDataLoaded = false; + } #if !UE_BUILD_SHIPPING FMobiusMemSnapshot::Take(TEXT("RunnableCleanup_AfterReset")).LogDelta(SnapCleanupStart); @@ -590,9 +595,11 @@ void UMassEntitySpawnSubsystem::LoadPedestrianData() // Cleanup any existing runnable to avoid memory leaks AgentDataRunnableCleanup(AgentDataSubsystem->JsonDataRunnable); - // Get the JSON Data File using the FRunnable class to get the data asynchronously - AgentDataSubsystem->JsonDataRunnable = MakeUnique(JSONDataFile, AgentDataSubsystem); + // Bind delegate BEFORE creating the runnable so we never miss a completion if the thread is very fast AgentDataSubsystem->OnLoadSimulationDataComplete.AddDynamic(this, &UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData); + + // Get the JSON Data File using the FRunnable class to get the data asynchronously + AgentDataSubsystem->JsonDataRunnable = MakeUnique(JSONDataFile, AgentDataSubsystem); //AgentDataSubsystem->OnMaxAgentCount.AddDynamic(AgentDataSubsystem, &UAgentDataSubsystem::UpdateMaxAgentCount); // check if the widget subsystem is valid From 95d55388ff3322cc1862d4a5bf16be4f1cd422ce Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:06:06 +1200 Subject: [PATCH 11/12] Splash image Visual tweaks and resize Discovered the edited resolutions sizes did not transfer over correctly, have since corrected this and sharpened the image with the assist of ChatGPT to sharpen the image --- ImportedOpenSourceAssets/MobiusSplashResize.bmp | 4 ++-- ImportedOpenSourceAssets/MobiusSplashResize.png | 4 ++-- UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp | 4 ++-- UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png | 3 --- UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset | 3 --- .../ProjectMobius/Content/Splash/ProjectMobius.uasset | 3 --- UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp | 4 ++-- UnrealFolder/ProjectMobius/Content/Splash/Splash.png | 4 ++-- UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset | 3 --- 9 files changed, 10 insertions(+), 22 deletions(-) delete mode 100644 UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png delete mode 100644 UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset delete mode 100644 UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset delete mode 100644 UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset diff --git a/ImportedOpenSourceAssets/MobiusSplashResize.bmp b/ImportedOpenSourceAssets/MobiusSplashResize.bmp index 572b5d991..7de588af5 100644 --- a/ImportedOpenSourceAssets/MobiusSplashResize.bmp +++ b/ImportedOpenSourceAssets/MobiusSplashResize.bmp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 -size 2003082 +oid sha256:29527642c7cd130eabd22abdafb99699e79a9d87fa849c08c1b52f65cbf53652 +size 1573002 diff --git a/ImportedOpenSourceAssets/MobiusSplashResize.png b/ImportedOpenSourceAssets/MobiusSplashResize.png index 572b5d991..272c98f1b 100644 --- a/ImportedOpenSourceAssets/MobiusSplashResize.png +++ b/ImportedOpenSourceAssets/MobiusSplashResize.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 -size 2003082 +oid sha256:0fd695ea6fbb2f2e2de7aac1a940a780a860ff8e242efd7818a771ce3d76f33e +size 1576091 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp index 572b5d991..7de588af5 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp +++ b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 -size 2003082 +oid sha256:29527642c7cd130eabd22abdafb99699e79a9d87fa849c08c1b52f65cbf53652 +size 1573002 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png deleted file mode 100644 index 572b5d991..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 -size 2003082 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset deleted file mode 100644 index b8b9d2478..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e2b4f84122e6670257fda789954a8a692321ee53ebeddbdaffb0f6a9dcd45bc -size 366275 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset b/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset deleted file mode 100644 index 101e7e1be..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4d6bb3230544605ff0122f6fafae76581d66d419182484f4b6f14a89aec4b521 -size 64786 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp b/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp index 572b5d991..7de588af5 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp +++ b/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 -size 2003082 +oid sha256:29527642c7cd130eabd22abdafb99699e79a9d87fa849c08c1b52f65cbf53652 +size 1573002 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.png b/UnrealFolder/ProjectMobius/Content/Splash/Splash.png index 572b5d991..272c98f1b 100644 --- a/UnrealFolder/ProjectMobius/Content/Splash/Splash.png +++ b/UnrealFolder/ProjectMobius/Content/Splash/Splash.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98ecffe3d47b24b797344044df50a273d58ec32f095ba01c90fe3f788a0038e0 -size 2003082 +oid sha256:0fd695ea6fbb2f2e2de7aac1a940a780a860ff8e242efd7818a771ce3d76f33e +size 1576091 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset b/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset deleted file mode 100644 index ad4dfa84b..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cc5b2772b627fd71890084eb87b8fe95cafac907b3303efffe33a387ff3ec08d -size 366229 From 3fc20c034fc48383cac84f4414be2428f5a2df6c Mon Sep 17 00:00:00 2001 From: Nicholas Harding <40708936+sir306@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:23:42 +1200 Subject: [PATCH 12/12] Staggered mesh/tile section emits and batching Refactor heatmap and runtime mesh building to emit geometry in multiple sections and pump those sections across frames to avoid large game-thread hitches. Adds tile-based mesh construction, per-tile/per-section material assignment, and a ticked EmitNextTileSection/EmitNextChunkSection flow with FinalizeTileEmit/FinalizeMeshEmit to stagger CreateMeshSection calls. Introduces SplitSubmeshByTriCap and submesh chunking to cap tris per section, enables async cooking for procedural meshes, defers Datasmith heatmap broadcast until components are ready, and adds detailed timing/logging via UMobiusCustomLoggerSubsystem. Also hardens checks for multi-section meshes, cleans up ticker handles in EndPlay, and preserves legacy single-section fallback when batching is disabled. --- .../Actors/HeatmapPixelTextureVisualizer.cpp | 500 ++++++++++++------ .../Private/AsyncAssimpMeshLoader.cpp | 90 +++- .../BuildingGenerator/RuntimeMeshBuilder.cpp | 333 ++++++++++-- .../MobiusCustomLoggerSubsystem.cpp | 15 +- .../Actors/HeatmapPixelTextureVisualizer.h | 62 ++- .../MobiusCore/Public/AsyncAssimpMeshLoader.h | 40 ++ .../BuildingGenerator/RuntimeMeshBuilder.h | 57 ++ 7 files changed, 900 insertions(+), 197 deletions(-) diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp index aea4d54b4..3e496cc79 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp @@ -34,6 +34,9 @@ #include "StaticMeshResources.h" // used for accessing vertex buffers on static meshes #include "Rendering/PositionVertexBuffer.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" +#include "Subsystems/MobiusCustomLoggerSubsystem.h" +#include "Engine/Engine.h" +#include "HAL/PlatformTime.h" // Sets default values @@ -111,10 +114,23 @@ void AHeatmapPixelTextureVisualizer::PostInitializeComponents() //#if WITH_EDITOR CreateMaterialInstances(); - // check mesh is valid - if(RuntimeHeatmapMeshComponent->GetProcMeshSection(0)) - { - HeatmapMeshSize2D = FVector2D(RuntimeHeatmapMeshComponent->GetProcMeshSection(0)->SectionLocalBox.GetSize().X, RuntimeHeatmapMeshComponent->GetProcMeshSection(0)->SectionLocalBox.GetSize().Y); + // Aggregate bounds across every section so the dense tiled path (N sections) reports whole-mesh size, + // not a single tile's bounds. + if (RuntimeHeatmapMeshComponent->GetNumSections() > 0) + { + FBox Agg(ForceInit); + const int32 NumSections = RuntimeHeatmapMeshComponent->GetNumSections(); + for (int32 i = 0; i < NumSections; ++i) + { + if (const FProcMeshSection* Sec = RuntimeHeatmapMeshComponent->GetProcMeshSection(i)) + { + Agg += Sec->SectionLocalBox; + } + } + if (Agg.IsValid) + { + HeatmapMeshSize2D = FVector2D(Agg.GetSize().X, Agg.GetSize().Y); + } } // Assign the Material Instance to the mesh depending on the heatmap type @@ -134,37 +150,24 @@ void AHeatmapPixelTextureVisualizer::AssignMaterialInstanceToMesh() const { return; } - if(HeatmapType) + UMaterialInstanceDynamic* Target = HeatmapType ? HeatmapMaterialInstance.Get() : VoronoiMaterialInstance.Get(); + if (!Target) { - if (!HeatmapMaterialInstance) + if (UMobiusUserFeedbackSubsystem* Feedback = UMobiusUserFeedbackSubsystem::Get(this)) { - if (UMobiusUserFeedbackSubsystem* Feedback = UMobiusUserFeedbackSubsystem::Get(this)) - { - Feedback->ReportError( - FText::FromString("Heatmap Setup Error"), - FText::FromString("Heatmap material missing"), - FText::FromString("Heatmap material instance is not available."), - FText::FromString("HeatmapPixelTextureVisualizer")); - } - return; + Feedback->ReportError( + FText::FromString("Heatmap Setup Error"), + FText::FromString(HeatmapType ? "Heatmap material missing" : "Voronoi material missing"), + FText::FromString(HeatmapType ? "Heatmap material instance is not available." : "Voronoi material instance is not available."), + FText::FromString("HeatmapPixelTextureVisualizer")); } - RuntimeHeatmapMeshComponent->SetMaterial(0, HeatmapMaterialInstance); + return; } - else + // Tiled dense path emits N sections; apply MID to every one so batched draws share the same instance. + const int32 NumSections = RuntimeHeatmapMeshComponent->GetNumSections(); + for (int32 i = 0; i < NumSections; ++i) { - if (!VoronoiMaterialInstance) - { - if (UMobiusUserFeedbackSubsystem* Feedback = UMobiusUserFeedbackSubsystem::Get(this)) - { - Feedback->ReportError( - FText::FromString("Heatmap Setup Error"), - FText::FromString("Voronoi material missing"), - FText::FromString("Voronoi material instance is not available."), - FText::FromString("HeatmapPixelTextureVisualizer")); - } - return; - } - RuntimeHeatmapMeshComponent->SetMaterial(0, VoronoiMaterialInstance); + RuntimeHeatmapMeshComponent->SetMaterial(i, Target); } } @@ -321,8 +324,8 @@ void AHeatmapPixelTextureVisualizer::SetupDynamicTexture() const UE_LOG(LogTemp, Warning, TEXT("DynamicTexture is not valid")); return; } - // check static mesh and material instance is valid - if(!RuntimeHeatmapMeshComponent || !RuntimeHeatmapMeshComponent->GetProcMeshSection(0) || !HeatmapMaterialInstance || !VoronoiMaterialInstance) + // check static mesh and material instance is valid (any section will do — dense path may emit N) + if(!RuntimeHeatmapMeshComponent || RuntimeHeatmapMeshComponent->GetNumSections() == 0 || !HeatmapMaterialInstance || !VoronoiMaterialInstance) { if (UMobiusUserFeedbackSubsystem* Feedback = UMobiusUserFeedbackSubsystem::Get(this)) { @@ -674,12 +677,12 @@ void AHeatmapPixelTextureVisualizer::ClearTexture() void AHeatmapPixelTextureVisualizer::UpdateMeshSize(const FVector2D& NewMeshSize) { - // check if the mesh is valid - if(!RuntimeHeatmapMeshComponent->GetProcMeshSection(0)) + // check if the mesh is valid (any section) + if (!RuntimeHeatmapMeshComponent || RuntimeHeatmapMeshComponent->GetNumSections() == 0) { return; } - + // update the mesh size HeatmapMeshSize2D = NewMeshSize; @@ -690,8 +693,28 @@ void AHeatmapPixelTextureVisualizer::UpdateMeshSize(const FVector2D& NewMeshSize MeshVertices.Add(FVector(HeatmapMeshSize2D.X, HeatmapMeshSize2D.Y, 0)); MeshVertices.Add(FVector(HeatmapMeshSize2D.X, 0, 0)); - // update the mesh - RuntimeHeatmapMeshComponent->UpdateMeshSection(0, MeshVertices, TArray(), MeshUVs, TArray(), TArray()); + // Drop any tiled sections and rebuild as a single 4-vert plane. This path only runs for the simple + // (non-dense) case; dense heatmaps should retrigger GenerateMeshVerticesUVsAndTriangles instead. + if (TileEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(TileEmitTickerHandle); + TileEmitTickerHandle.Reset(); + } + RuntimeHeatmapMeshComponent->ClearAllMeshSections(); + Tiles.Reset(); + PendingTileEmitIndex = 0; + + const double PushStart = FPlatformTime::Seconds(); + RuntimeHeatmapMeshComponent->CreateMeshSection(0, MeshVertices, MeshTriangles, TArray(), MeshUVs, + TArray(), TArray(), false); + const double PushDurationMs = (FPlatformTime::Seconds() - PushStart) * 1000.0; + if (UMobiusCustomLoggerSubsystem* StartupLogger = GEngine ? GEngine->GetEngineSubsystem() : nullptr) + { + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("[Heatmap %s floor=%d] UpdateMeshSize single-section rebuild verts=%d tris=%d in %.2f ms"), + *ActorName, FloorID, MeshVertices.Num(), MeshTriangles.Num() / 3, PushDurationMs)); + } + AssignMaterialInstanceToMesh(); } void AHeatmapPixelTextureVisualizer::UpdateHeatmapType(bool bIsStandardHeatmap, bool bIsLiveTrackingNeeded) @@ -744,8 +767,15 @@ void AHeatmapPixelTextureVisualizer::UpdateHeatmapMeshBounds() void AHeatmapPixelTextureVisualizer::BuildGridMeshPlane(const FVector2D& MeshSize, bool bIsStandardHeatmap) { - // Clear mesh section - RuntimeHeatmapMeshComponent->ClearMeshSection(0); + // Drop every section (dense path may have emitted N). Simple BuildGridMeshPlane only ever produces one. + if (TileEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(TileEmitTickerHandle); + TileEmitTickerHandle.Reset(); + } + RuntimeHeatmapMeshComponent->ClearAllMeshSections(); + Tiles.Reset(); + PendingTileEmitIndex = 0; FIntPoint NumTriangles = FIntPoint(2);// if the heatmap is standard we only need 2 triangles (till we get to clipping) @@ -754,7 +784,7 @@ void AHeatmapPixelTextureVisualizer::BuildGridMeshPlane(const FVector2D& MeshSiz // Calculate the number of triangles NumTriangles = FIntPoint(MeshSize.X / 25, MeshSize.Y / 25); } - + // Clear any existing vertices, UVs and triangles MeshVertices.Empty(); MeshUVs.Empty(); @@ -772,9 +802,18 @@ void AHeatmapPixelTextureVisualizer::BuildGridMeshPlane(const FVector2D& MeshSiz UKismetProceduralMeshLibrary::CreateGridMeshWelded(NumTriangles.X, NumTriangles.Y, Self->MeshTriangles, Self->MeshVertices, Self->MeshUVs, 25); // Update the mesh + const double PushStart = FPlatformTime::Seconds(); Self->RuntimeHeatmapMeshComponent->CreateMeshSection(0, Self->MeshVertices, Self->MeshTriangles, TArray(), Self->MeshUVs, TArray(), TArray(), false); + const double PushDurationMs = (FPlatformTime::Seconds() - PushStart) * 1000.0; + if (UMobiusCustomLoggerSubsystem* BuildLog = GEngine ? GEngine->GetEngineSubsystem() : nullptr) + { + BuildLog->EnqueueLogMessage(FString::Printf( + TEXT("[Heatmap %s floor=%d] BuildGridMeshPlane CreateMeshSection verts=%d tris=%d in %.2f ms (off-GT)"), + *Self->ActorName, Self->FloorID, Self->MeshVertices.Num(), Self->MeshTriangles.Num() / 3, PushDurationMs)); + } + Self->AssignMaterialInstanceToMesh(); }); - + } void AHeatmapPixelTextureVisualizer::UpdateHeatmapCVDSettings(EColorVisionDeficiency ColourDeficiency, @@ -862,114 +901,89 @@ FIntPoint AHeatmapPixelTextureVisualizer::CalculateNumberOfTriangles(const FVect return NumTriangles; } -void AHeatmapPixelTextureVisualizer::CreateMeshVertexsAndUVs(const FIntPoint NumTriangles, const FVector2D CellSize) +void AHeatmapPixelTextureVisualizer::BuildTileBuffers(int32 TileX0, int32 TileY0, int32 TileX1, int32 TileY1, + const FIntPoint& NumTriangles, const FVector2D& CellSize, + const TArray& Quads, FHeatmapTile& Out) const { - TRACE_CPUPROFILER_EVENT_SCOPE_STR("Generate Mesh Vertices and UVs"); - // Clear any existing vertices and UVs - MeshVertices.Empty(); - MeshUVs.Empty(); + // UV aspect correction matches the legacy CreateMeshVertexsAndUVs derivation so world-space UV math + // remains unchanged across the tile boundaries — no seams in the dynamic-texture sampling. + const bool bAdjustY = HeatmapMeshSize2D.X >= HeatmapMeshSize2D.Y; + const float AspectRatio = bAdjustY ? (HeatmapMeshSize2D.Y / HeatmapMeshSize2D.X) + : (HeatmapMeshSize2D.X / HeatmapMeshSize2D.Y); - // Clear any existing vertices and UVs - // Assume the texture is square. If the mesh is wider than tall, adjust the Y UVs. - bool bAdjustY = HeatmapMeshSize2D.X >= HeatmapMeshSize2D.Y; - // The aspect ratio here is the ratio of the smaller dimension to the larger. - float AspectRatio = bAdjustY ? (HeatmapMeshSize2D.Y / HeatmapMeshSize2D.X) : (HeatmapMeshSize2D.X / HeatmapMeshSize2D.Y); - - // Generate vertices and UVs - for (int32 y = 0; y < NumTriangles.Y; y++) + // Global-grid-index -> local-tile-vert-index. Only verts referenced by kept quads are materialised. + TMap GlobalToLocal; + GlobalToLocal.Reserve((TileX1 - TileX0 + 1) * (TileY1 - TileY0 + 1)); + + auto AddOrGetVert = [&](int32 gx, int32 gy) -> int32 { - for (int32 x = 0; x < NumTriangles.X; x++) + const int32 GlobalIdx = gx + gy * NumTriangles.X; + if (const int32* Existing = GlobalToLocal.Find(GlobalIdx)) { - // Create vertex position (Z is fixed to 0.1) - FVector Vertex = FVector(x * CellSize.X, y * CellSize.Y, 0.1f); - - // Base UV coordinates mapped over the full [0,1] range - float UVx = static_cast(x) / (NumTriangles.X - 1); - float UVy = static_cast(y) / (NumTriangles.Y - 1); - - // Adjust the UVs to account for non-square mesh dimensions: - if (bAdjustY) - { - // For a wider-than-tall mesh, scale the Y component. - // This makes the effective vertical UV range equal to the mesh's aspect ratio. - // The offset centers the texture vertically. - UVy = UVy * AspectRatio + (1.0f - AspectRatio) * 0.5f; - } - else - { - // For a taller-than-wide mesh, you could similarly adjust UVx: - UVx = UVx * AspectRatio + (1.0f - AspectRatio) * 0.5f; - } - - FVector2d UV(UVx, UVy); - MeshVertices.Add(Vertex); - MeshUVs.Add(UV); + return *Existing; } - } -} -void AHeatmapPixelTextureVisualizer::GenerateMeshTrianglesInQuadMapping(const FIntPoint NumTriangles, TArray Quads) -{ - TRACE_CPUPROFILER_EVENT_SCOPE_STR("Generate Triangles in Quad Mapping"); - // Clear any existing triangles - MeshTriangles.Empty(); - - for (int32 y = 0; y < NumTriangles.Y - 1; y++) - { - for (int32 x = 0; x < NumTriangles.X - 1; x++) + FVector Vertex(gx * CellSize.X, gy * CellSize.Y, 0.1f); + float UVx = static_cast(gx) / (NumTriangles.X - 1); + float UVy = static_cast(gy) / (NumTriangles.Y - 1); + if (bAdjustY) { - // Calculate the 6 indices for the 2 triangles - int32 Index0 = x + y * NumTriangles.X; - int32 Index1 = Index0 + NumTriangles.X; - int32 Index2 = Index0 + 1; - int32 Index3 = Index1; - int32 Index4 = Index1 + 1; - int32 Index5 = Index2; + UVy = UVy * AspectRatio + (1.0f - AspectRatio) * 0.5f; + } + else + { + UVx = UVx * AspectRatio + (1.0f - AspectRatio) * 0.5f; + } - - if(Quads.Num()>0) + const int32 LocalIdx = Out.Verts.Add(Vertex); + Out.UVs.Add(FVector2D(UVx, UVy)); + GlobalToLocal.Add(GlobalIdx, LocalIdx); + return LocalIdx; + }; + + // Walk every quad cell in the tile range. Preserve the quad-intersect filter so ignored regions + // (outside the building footprint) still produce zero geometry. + for (int32 y = TileY0; y < TileY1; ++y) + { + for (int32 x = TileX0; x < TileX1; ++x) + { + bool bKeep = Quads.Num() == 0; + if (!bKeep) { - // loop over the quads and see if the triangle is within the quad - for(FBox3d Quad : Quads) + const FVector V0 = FVector(x * CellSize.X, y * CellSize.Y, 0.1f) + MeshOriginLocation; + const FVector V1 = FVector(x * CellSize.X, (y + 1) * CellSize.Y, 0.1f) + MeshOriginLocation; + const FVector V2 = FVector((x + 1) * CellSize.X, y * CellSize.Y, 0.1f) + MeshOriginLocation; + const FVector V3 = FVector((x + 1) * CellSize.X, (y + 1) * CellSize.Y, 0.1f) + MeshOriginLocation; + for (const FBox3d& Quad : Quads) { - // as the vertices are in local space we need to convert to global space - FVector Vert_0 = MeshVertices[Index0] + MeshOriginLocation; - FVector Vert_1 = MeshVertices[Index1] + MeshOriginLocation; - FVector Vert_2 = MeshVertices[Index2] + MeshOriginLocation; - FVector Vert_3 = MeshVertices[Index3] + MeshOriginLocation; - - // check if triangle vertices are within or on the quad bounds - if(Quad.IsInsideOrOn(Vert_0) || Quad.IsInsideOrOn(Vert_1) || - Quad.IsInsideOrOn(Vert_2) || Quad.IsInsideOrOn(Vert_3)) + if (Quad.IsInsideOrOn(V0) || Quad.IsInsideOrOn(V1) || + Quad.IsInsideOrOn(V2) || Quad.IsInsideOrOn(V3)) { - // Add the triangle indices - As we want uniform boxes we will say that the quad is valid - MeshTriangles.Add(Index0); - MeshTriangles.Add(Index1); - MeshTriangles.Add(Index2); - MeshTriangles.Add(Index3); - MeshTriangles.Add(Index4); - MeshTriangles.Add(Index5); - break; // found a valid quad so break + bKeep = true; + break; } } } - else + if (!bKeep) { - // Should have a valid quad but for now we will just add the triangles - - // Add the triangle indices - MeshTriangles.Add(Index0); - MeshTriangles.Add(Index1); - MeshTriangles.Add(Index2); - MeshTriangles.Add(Index3); - MeshTriangles.Add(Index4); - MeshTriangles.Add(Index5); + continue; } - + + // Matches legacy index pattern: (Idx0,Idx1,Idx2) + (Idx1,Idx4,Idx2) where + // Idx0=(x,y), Idx1=(x,y+1), Idx2=(x+1,y), Idx4=(x+1,y+1). + const int32 L0 = AddOrGetVert(x, y); + const int32 L1 = AddOrGetVert(x, y + 1); + const int32 L2 = AddOrGetVert(x + 1, y); + const int32 L3 = AddOrGetVert(x + 1, y + 1); + + Out.Tris.Add(L0); + Out.Tris.Add(L1); + Out.Tris.Add(L2); + Out.Tris.Add(L1); + Out.Tris.Add(L3); + Out.Tris.Add(L2); } } - // Log the number of triangles - UE_LOG(LogTemp, Warning, TEXT("Number of Triangles: %d"), MeshTriangles.Num()); } void AHeatmapPixelTextureVisualizer::GenerateMeshVerticesUVsAndTriangles(const FVector2D& MeshSize, @@ -977,9 +991,19 @@ void AHeatmapPixelTextureVisualizer::GenerateMeshVerticesUVsAndTriangles(const F { // Update the mesh size HeatmapMeshSize2D = MeshSize; - - // clear the previous mesh section - RuntimeHeatmapMeshComponent->ClearMeshSection(0); + + // Do NOT ClearAllMeshSections here: SetupDynamicTexture runs synchronously right after this call + // and gates on GetNumSections() > 0. Dropping the default constructor-emitted section 0 would make + // the dynamic texture bail with "Heatmap resources missing". Clearing is deferred to the GT emit + // continuation just before the tiles are pushed, so the old sections remain live until replaced. + // Cancel any in-flight emit from a previous call so its stale tiles don't stomp this generation. + if (TileEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(TileEmitTickerHandle); + TileEmitTickerHandle.Reset(); + } + Tiles.Reset(); + PendingTileEmitIndex = 0; // Number of required triangles FIntPoint NumTriangles = FIntPoint(MeshSize.X / 250, MeshSize.Y / 250); @@ -1051,23 +1075,63 @@ void AHeatmapPixelTextureVisualizer::GenerateMeshVerticesUVsAndTriangles(const F MeshTriangles.Empty(); } - + // Snap tile size to >=4 cells; user-visible UPROPERTY clamps at 4 already but a direct member poke could slip through. + const int32 LocalTileSize = FMath::Max(4, GridTileSize); + // Capture via TWeakObjectPtr so a destroyed actor doesn't leave the // worker / GT continuation dereferencing freed members during a file switch. TWeakObjectPtr WeakThis(this); - Async(EAsyncExecution::ThreadPool, [WeakThis, NumTriangles, CellSize, MeshBuilder]() + Async(EAsyncExecution::ThreadPool, [WeakThis, NumTriangles, CellSize, MeshBuilder, LocalTileSize]() { AHeatmapPixelTextureVisualizer* Self = WeakThis.Get(); if (!Self) return; // Generate the quads to restrict the triangle generation to areas needed - TArray Quads = Self->FindAllQuads(MeshBuilder); + const TArray Quads = Self->FindAllQuads(MeshBuilder); - // Generate the vertices and UVs - Self->CreateMeshVertexsAndUVs(NumTriangles, CellSize); + // Number of quad cells (vertex grid is NumTriangles.X x NumTriangles.Y). + const int32 QuadsX = FMath::Max(0, NumTriangles.X - 1); + const int32 QuadsY = FMath::Max(0, NumTriangles.Y - 1); - // Generate the Triangles for this square - Self->GenerateMeshTrianglesInQuadMapping(NumTriangles, Quads); + TArray LocalTiles; + if (Self->bEnableMultiSectionBatching && LocalTileSize > 0) + { + // Tile the quad grid; tiles with zero kept triangles are discarded so empty regions cost nothing. + const int32 NumTileCols = FMath::DivideAndRoundUp(QuadsX, LocalTileSize); + const int32 NumTileRows = FMath::DivideAndRoundUp(QuadsY, LocalTileSize); + LocalTiles.Reserve(NumTileCols * NumTileRows); + + for (int32 y0 = 0; y0 < QuadsY; y0 += LocalTileSize) + { + const int32 y1 = FMath::Min(y0 + LocalTileSize, QuadsY); + for (int32 x0 = 0; x0 < QuadsX; x0 += LocalTileSize) + { + const int32 x1 = FMath::Min(x0 + LocalTileSize, QuadsX); + FHeatmapTile Tile; + Self->BuildTileBuffers(x0, y0, x1, y1, NumTriangles, CellSize, Quads, Tile); + if (Tile.Tris.Num() > 0) + { + LocalTiles.Emplace(MoveTemp(Tile)); + } + } + } + } + else + { + // Legacy single-section fallback for the rollback flag. + FHeatmapTile Whole; + Self->BuildTileBuffers(0, 0, QuadsX, QuadsY, NumTriangles, CellSize, Quads, Whole); + if (Whole.Tris.Num() > 0) + { + LocalTiles.Emplace(MoveTemp(Whole)); + } + } + + // Hand the built tiles over to the actor for the GT emit stage. + if (AHeatmapPixelTextureVisualizer* Alive = WeakThis.Get()) + { + Alive->Tiles = MoveTemp(LocalTiles); + } }, [WeakThis] { @@ -1076,8 +1140,36 @@ void AHeatmapPixelTextureVisualizer::GenerateMeshVerticesUVsAndTriangles(const F AHeatmapPixelTextureVisualizer* Self = WeakThis.Get(); if (!Self || !IsValid(Self->RuntimeHeatmapMeshComponent)) return; - // Generate the mesh section - Self->RuntimeHeatmapMeshComponent->CreateMeshSection_LinearColor(0, Self->MeshVertices, Self->MeshTriangles, TArray(), Self->MeshUVs, TArray(), TArray(), false); + // Clear existing sections then hand tile emit to the staggered pump. Emitting every + // tile in one tick burns FScene_AddPrimitive on the GT (bigger grids = bigger hitch); + // the pump spreads the cost across frames. Material apply runs once in FinalizeTileEmit + // so tiles picked up before the final flush may render unlit for a frame or two — + // acceptable trade for eliminating the spike. + if (UMobiusCustomLoggerSubsystem* KickoffLog = GEngine ? GEngine->GetEngineSubsystem() : nullptr) + { + KickoffLog->EnqueueLogMessage(FString::Printf( + TEXT("[Heatmap %s floor=%d] Tile emit kickoff tiles=%d"), + *Self->ActorName, Self->FloorID, Self->Tiles.Num())); + } + Self->RuntimeHeatmapMeshComponent->ClearAllMeshSections(); + Self->PendingTileEmitIndex = 0; + Self->TileEmitStartTime = FPlatformTime::Seconds(); + + if (Self->TileEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(Self->TileEmitTickerHandle); + Self->TileEmitTickerHandle.Reset(); + } + + if (Self->Tiles.Num() == 0) + { + Self->FinalizeTileEmit(); + return; + } + + Self->TileEmitTickerHandle = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateUObject(Self, &AHeatmapPixelTextureVisualizer::EmitNextTileSection), + 0.0f); }); }); @@ -1095,7 +1187,108 @@ void AHeatmapPixelTextureVisualizer::GenerateMeshVerticesUVsAndTriangles(const F // // // Generate the mesh section // RuntimeHeatmapMeshComponent->CreateMeshSection_LinearColor(0, MeshVertices, MeshTriangles, TArray(), MeshUVs, TArray(), TArray(), false); - + +} + +bool AHeatmapPixelTextureVisualizer::EmitNextTileSection(float /*DeltaTime*/) +{ + if (!IsValid(RuntimeHeatmapMeshComponent)) + { + Tiles.Empty(); + PendingTileEmitIndex = 0; + TileEmitTickerHandle.Reset(); + return false; + } + + // Pick the material up-front so we can set it per section as each tile goes live, rather than + // shipping tiles with default material until FinalizeTileEmit runs one pass at the end. + UMaterialInstanceDynamic* TargetMaterial = HeatmapType ? HeatmapMaterialInstance.Get() : VoronoiMaterialInstance.Get(); + + UMobiusCustomLoggerSubsystem* StartupLogger = + GEngine ? GEngine->GetEngineSubsystem() : nullptr; + + const int32 TilesThisTick = FMath::Max(1, SectionsEmittedPerTick); + for (int32 Pushed = 0; Pushed < TilesThisTick && PendingTileEmitIndex < Tiles.Num(); ++Pushed) + { + const int32 SectionIdx = PendingTileEmitIndex++; + FHeatmapTile& T = Tiles[SectionIdx]; + + const int32 TileVerts = T.Verts.Num(); + const int32 TileTris = T.Tris.Num() / 3; + const double PushStart = FPlatformTime::Seconds(); + + RuntimeHeatmapMeshComponent->CreateMeshSection_LinearColor( + SectionIdx, T.Verts, T.Tris, TArray(), T.UVs, + TArray(), TArray(), false); + + if (TargetMaterial) + { + RuntimeHeatmapMeshComponent->SetMaterial(SectionIdx, TargetMaterial); + } + + const double PushDurationMs = (FPlatformTime::Seconds() - PushStart) * 1000.0; + if (StartupLogger) + { + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("[Heatmap %s floor=%d] CreateMeshSection section=%d verts=%d tris=%d in %.2f ms"), + *ActorName, FloorID, SectionIdx, TileVerts, TileTris, PushDurationMs)); + } + UE_LOG(LogTemp, Log, + TEXT("[Heatmap %s floor=%d] CreateMeshSection section=%d verts=%d tris=%d in %.2f ms"), + *ActorName, FloorID, SectionIdx, TileVerts, TileTris, PushDurationMs); + + // Free the per-tile CPU buffers now that the component owns the data. + T.Verts.Empty(); + T.Tris.Empty(); + T.UVs.Empty(); + } + + if (PendingTileEmitIndex >= Tiles.Num()) + { + FinalizeTileEmit(); + return false; + } + + return true; +} + +void AHeatmapPixelTextureVisualizer::FinalizeTileEmit() +{ + const int32 EmittedSections = Tiles.Num(); + + Tiles.Empty(); + PendingTileEmitIndex = 0; + TileEmitTickerHandle.Reset(); + + if (IsValid(RuntimeHeatmapMeshComponent)) + { + AssignMaterialInstanceToMesh(); + } + + const double DurationMs = (FPlatformTime::Seconds() - TileEmitStartTime) * 1000.0; + if (UMobiusCustomLoggerSubsystem* StartupLogger = GEngine ? GEngine->GetEngineSubsystem() : nullptr) + { + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("[Heatmap %s floor=%d] Tile emit finished sections=%d in %.2f ms"), + *ActorName, FloorID, EmittedSections, DurationMs)); + } + UE_LOG(LogTemp, Log, TEXT("[Heatmap %s floor=%d] Tile emit finished sections=%d in %.2f ms"), + *ActorName, FloorID, EmittedSections, DurationMs); +} + +void AHeatmapPixelTextureVisualizer::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Kill the tile emit pump before the actor goes away — the ticker holds a UObject binding + // to `this` and would fire again post-teardown otherwise. + if (TileEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(TileEmitTickerHandle); + TileEmitTickerHandle.Reset(); + } + Tiles.Empty(); + PendingTileEmitIndex = 0; + + Super::EndPlay(EndPlayReason); } TArray AHeatmapPixelTextureVisualizer::FindAllQuads(ARuntimeMeshBuilder* MeshBuilder) const @@ -1176,13 +1369,22 @@ TArray AHeatmapPixelTextureVisualizer::FindAllQuads(ARuntimeMeshBuilder* } else // is a procedural mesh { - // loop over the mesh vertices - for(FProcMeshVertex VertexStruct : MeshBuilder->MobiusProceduralMeshComponent->GetProcMeshSection(0)->ProcVertexBuffer) + // Building mesh is now batched into N sections; walk every one so quad detection sees the full mesh. + UProceduralMeshComponent* BuildingComp = MeshBuilder->MobiusProceduralMeshComponent; + if (BuildingComp) { - // while working out the algorithm to work out the mesh perimeter we will just loop over vertices that have a z value of 0 +/- 100 - if(VertexStruct.Position.Z <= StartPos.Z + StepSize && VertexStruct.Position.Z >= StartPos.Z - StepSize) + const int32 NumSections = BuildingComp->GetNumSections(); + for (int32 s = 0; s < NumSections; ++s) { - ValidVertices.Add(VertexStruct.Position); + const FProcMeshSection* Sec = BuildingComp->GetProcMeshSection(s); + if (!Sec) continue; + for (const FProcMeshVertex& VertexStruct : Sec->ProcVertexBuffer) + { + if (VertexStruct.Position.Z <= StartPos.Z + StepSize && VertexStruct.Position.Z >= StartPos.Z - StepSize) + { + ValidVertices.Add(VertexStruct.Position); + } + } } } } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp index 1f0b873e4..4edd319e9 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/AsyncAssimpMeshLoader.cpp @@ -106,6 +106,66 @@ TArray UAsyncAssimpMeshLoader::TriangulateWktPolygon(const TArray& Out) +{ + const int32 TotalTris = In.Faces.Num() / 3; + if (TotalTris == 0) + { + return; + } + + if (MaxTris <= 0 || TotalTris <= MaxTris) + { + Out.Add(In); + return; + } + + const bool bHasUV = (In.UV.Num() == In.Vertices.Num()); + const bool bHasNormals = (In.Normals.Num() == In.Vertices.Num()); + + TMap OldToNew; + OldToNew.Reserve(MaxTris * 3); + + int32 TriCursor = 0; + while (TriCursor < TotalTris) + { + const int32 ChunkTriCount = FMath::Min(MaxTris, TotalTris - TriCursor); + + FAssimpSubmeshBuffers& Chunk = Out.AddDefaulted_GetRef(); + Chunk.Vertices.Reserve(ChunkTriCount * 3); + Chunk.Faces.Reserve(ChunkTriCount * 3); + if (bHasNormals) { Chunk.Normals.Reserve(ChunkTriCount * 3); } + if (bHasUV) { Chunk.UV.Reserve(ChunkTriCount * 3); } + + OldToNew.Reset(); + + for (int32 T = 0; T < ChunkTriCount; ++T) + { + for (int32 Corner = 0; Corner < 3; ++Corner) + { + const int32 OldIdx = In.Faces[(TriCursor + T) * 3 + Corner]; + int32* Existing = OldToNew.Find(OldIdx); + int32 NewIdx; + if (Existing) + { + NewIdx = *Existing; + } + else + { + NewIdx = Chunk.Vertices.Num(); + Chunk.Vertices.Add(In.Vertices[OldIdx]); + if (bHasNormals) { Chunk.Normals.Add(In.Normals[OldIdx]); } + if (bHasUV) { Chunk.UV.Add(In.UV[OldIdx]); } + OldToNew.Add(OldIdx, NewIdx); + } + Chunk.Faces.Add(NewIdx); + } + } + + TriCursor += ChunkTriCount; + } +} + FAssimpMeshLoaderRunnable::FAssimpMeshLoaderRunnable(const FString InPathToMesh, TWeakObjectPtr InWorldContextObject) : WorldContextObject(InWorldContextObject) { @@ -904,6 +964,9 @@ void FAssimpMeshLoaderRunnable::FillDataFromScene(const aiScene* Scene) FRotator Rotation = GetMeshRotation(AxisUpOrientation, AxisUpSign, AxisForwardOrientation, AxisForwardSign); + Submeshes.Reset(); + Submeshes.Reserve(Scene->mNumMeshes); + Vertices.Empty(); Faces.Empty(); Normals.Empty(); @@ -911,7 +974,10 @@ void FAssimpMeshLoaderRunnable::FillDataFromScene(const aiScene* Scene) for (uint32 MIndex = 0; MIndex < Scene->mNumMeshes; ++MIndex) { const aiMesh* Mesh = Scene->mMeshes[MIndex]; - int32 VertexBase = Vertices.Num(); + FAssimpSubmeshBuffers& Sub = Submeshes.AddDefaulted_GetRef(); + Sub.Vertices.Reserve(Mesh->mNumVertices); + Sub.Normals.Reserve(Mesh->mNumVertices); + Sub.Faces.Reserve(Mesh->mNumFaces * 3); for (uint32 NumVertices = 0; NumVertices < Mesh->mNumVertices; ++NumVertices) { @@ -922,7 +988,7 @@ void FAssimpMeshLoaderRunnable::FillDataFromScene(const aiScene* Scene) { TransformMeshMatrix(Vertex, AxisUpOrientation, AxisUpSign, AxisForwardOrientation, AxisForwardSign); } - Vertices.Add(Vertex); + Sub.Vertices.Add(Vertex); if (Mesh->HasNormals()) { @@ -943,11 +1009,11 @@ void FAssimpMeshLoaderRunnable::FillDataFromScene(const aiScene* Scene) Normal *= -1.0f; // WKT normals are inverted } - Normals.Add(Normal.GetSafeNormal()); + Sub.Normals.Add(Normal.GetSafeNormal()); } else { - Normals.Add(FVector::ZeroVector); + Sub.Normals.Add(FVector::ZeroVector); } } @@ -956,10 +1022,20 @@ void FAssimpMeshLoaderRunnable::FillDataFromScene(const aiScene* Scene) const aiFace& Face = Mesh->mFaces[FaceIndex]; if (Face.mNumIndices == 3) { - Faces.Add(VertexBase + Face.mIndices[0]); - Faces.Add(VertexBase + Face.mIndices[1]); - Faces.Add(VertexBase + Face.mIndices[2]); + Sub.Faces.Add(static_cast(Face.mIndices[0])); + Sub.Faces.Add(static_cast(Face.mIndices[1])); + Sub.Faces.Add(static_cast(Face.mIndices[2])); } } + + // Mirror into flat aggregate buffers for transitional callers still consuming monolithic data. + const int32 VertexBase = Vertices.Num(); + Vertices.Append(Sub.Vertices); + Normals.Append(Sub.Normals); + Faces.Reserve(Faces.Num() + Sub.Faces.Num()); + for (int32 Idx : Sub.Faces) + { + Faces.Add(VertexBase + Idx); + } } } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp index 353cc76df..97b4e5c50 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/BuildingGenerator/RuntimeMeshBuilder.cpp @@ -67,7 +67,10 @@ ARuntimeMeshBuilder::ARuntimeMeshBuilder() : RootComponent = MobiusProceduralMeshComponent; MobiusProceduralMeshComponent->bRenderInMainPass = true; - MobiusProceduralMeshComponent->bUseAsyncCooking = false; + // Async cooking spreads N-section collision cooks across worker threads instead of blocking the + // game thread. First clicks after load may briefly miss until the last section cook completes — + // acceptable: the loading widget covers the window. + MobiusProceduralMeshComponent->bUseAsyncCooking = true; MobiusProceduralMeshComponent->bUseComplexAsSimpleCollision = false; MobiusProceduralMeshComponent->bSelectable = true; MobiusProceduralMeshComponent->Mobility = EComponentMobility::Movable; @@ -230,9 +233,20 @@ void ARuntimeMeshBuilder::ReleaseDatasmithSceneResources() void ARuntimeMeshBuilder::EndPlay(const EEndPlayReason::Type EndPlayReason) { + // Kill the staggered emit pump before the actor goes away — the ticker holds a UObject + // binding to `this` and would fire again post-teardown otherwise. + if (ChunkEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(ChunkEmitTickerHandle); + ChunkEmitTickerHandle.Reset(); + } + PendingMeshChunks.Empty(); + PendingChunkEmitIndex = 0; + // Clear any pending items PendingCollisionEnable.Reset(); PendingDatasmithMeshes.Reset(); + bHeatmapBroadcastPending = false; // Drop our custom MID refs held per-mesh so the MIDs can be GC'd once the // components detach below. @@ -307,7 +321,39 @@ void ARuntimeMeshBuilder::GenerateMobiusMesh(TArray InVertices, TArray< ResetMeshCollisionAndPhysics(); - MobiusProceduralMeshComponent->CreateMeshSection(0, InVertices, InTriangles, InNormals, TArray(), TArray(), TArray(), false); + FAssimpSubmeshBuffers Input; + Input.Vertices = MoveTemp(InVertices); + Input.Faces = MoveTemp(InTriangles); + Input.Normals = MoveTemp(InNormals); + + TArray Chunks; + SplitSubmeshByTriCap(Input, MaxTrisPerSection, Chunks); + + static const TArray EmptyUVs; + static const TArray EmptyColors; + static const TArray EmptyTangents; + for (int32 ChunkIdx = 0; ChunkIdx < Chunks.Num(); ++ChunkIdx) + { + const FAssimpSubmeshBuffers& Chunk = Chunks[ChunkIdx]; + MobiusProceduralMeshComponent->CreateMeshSection( + ChunkIdx, + Chunk.Vertices, + Chunk.Faces, + Chunk.Normals, + EmptyUVs, + EmptyColors, + EmptyTangents, + false); + } + + if (MobiusMaterialInstanceDynamic != nullptr) + { + const int32 NumSections = MobiusProceduralMeshComponent->GetNumSections(); + for (int32 SectionIdx = 0; SectionIdx < NumSections; ++SectionIdx) + { + MobiusProceduralMeshComponent->SetMaterial(SectionIdx, MobiusMaterialInstanceDynamic); + } + } } void ARuntimeMeshBuilder::GetMeshDataFromFile(const FRotator MeshRotationOffset) @@ -421,8 +467,9 @@ void ARuntimeMeshBuilder::GetMeshDataFromFile(const FRotator MeshRotationOffset) void ARuntimeMeshBuilder::ResetMeshCollisionAndPhysics() { - // 1. Turn off async cooking for deterministic behavior here (optional but recommended), ensures it will update - MobiusProceduralMeshComponent->bUseAsyncCooking = false; + // 1. Keep async cooking on — see constructor comment. Disabling here would force a blocking cook + // against the empty body setup we're about to hand off, wasting the async worker path. + MobiusProceduralMeshComponent->bUseAsyncCooking = true; // 2. Clear all generated geometry + convex collision MobiusProceduralMeshComponent->ClearAllMeshSections(); // Empties sections + UpdateCollision() @@ -463,6 +510,7 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // Drop any queued work that still references old components PendingCollisionEnable.Reset(); PendingDatasmithMeshes.Reset(); + bHeatmapBroadcastPending = false; MaterialCache.Reset(); // Get the game instance @@ -777,15 +825,9 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() MobiusProceduralMeshComponent->SetCollisionResponseToAllChannels(ECR_Block); MobiusProceduralMeshComponent->SetSimulatePhysics(false); - - // A mesh section should only be created if successful - MobiusProceduralMeshComponent->CreateMeshSection_LinearColor(0, AsyncAssimpLoader->MeshLoaderRunnable->Vertices, - AsyncAssimpLoader->MeshLoaderRunnable->Faces, - AsyncAssimpLoader->MeshLoaderRunnable->Normals, - AsyncAssimpLoader->MeshLoaderRunnable->UV, - TArray(), - TArray(), - true/*set to true so we can use collisions - at a small cost of performance*/); + // Move the per-submesh buffers out of the runnable so we can tear down the loader before the + // (potentially slow) emit loop. Runnable retains empty TArrays — cheap to delete. + TArray LocalSubmeshes = MoveTemp(AsyncAssimpLoader->MeshLoaderRunnable->Submeshes); // The loader is no longer needed. Properly stop, drop the CPU-side mesh // buffers, and delete the runnable. The previous code path nulled @@ -802,8 +844,8 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() // after Run() returns — no manual Exit() from the game thread. ExistingRunnable->Stop(); - // CreateMeshSection_LinearColor above copies the arrays into the - // procedural mesh; the runnable's copies are dead weight from here on. + // Per-submesh buffers already moved out above. Clear the transitional flat mirrors so the + // runnable drops the last references before deletion. ExistingRunnable->Vertices.Empty(); ExistingRunnable->Faces.Empty(); ExistingRunnable->Normals.Empty(); @@ -816,20 +858,182 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() }); } - // if the material property is set then we want to apply our material to the mesh - if(MobiusMaterialInstanceDynamic != nullptr) + // Partition each submesh into triangle-capped chunks. Small submeshes pass through unchanged. + // Rollback: when bEnableMultiSectionBatching is false, concatenate everything into a single chunk + // that reproduces the legacy single-section-0 behaviour (with index remap for vertex offsets). + TArray Chunks; + if (bEnableMultiSectionBatching) + { + Chunks.Reserve(LocalSubmeshes.Num()); + for (const FAssimpSubmeshBuffers& Sub : LocalSubmeshes) + { + SplitSubmeshByTriCap(Sub, MaxTrisPerSection, Chunks); + } + } + else + { + int32 TotalVerts = 0; + int32 TotalFaces = 0; + for (const FAssimpSubmeshBuffers& Sub : LocalSubmeshes) + { + TotalVerts += Sub.Vertices.Num(); + TotalFaces += Sub.Faces.Num(); + } + FAssimpSubmeshBuffers Flat; + Flat.Vertices.Reserve(TotalVerts); + Flat.Faces.Reserve(TotalFaces); + Flat.Normals.Reserve(TotalVerts); + Flat.UV.Reserve(TotalVerts); + for (const FAssimpSubmeshBuffers& Sub : LocalSubmeshes) + { + const int32 VertexBase = Flat.Vertices.Num(); + Flat.Vertices.Append(Sub.Vertices); + Flat.Normals.Append(Sub.Normals); + Flat.UV.Append(Sub.UV); + Flat.Faces.Reserve(Flat.Faces.Num() + Sub.Faces.Num()); + for (int32 Index : Sub.Faces) + { + Flat.Faces.Add(Index + VertexBase); + } + } + Chunks.Add(MoveTemp(Flat)); + } + + if (StartupLogger) + { + int32 TotalVerts = 0; + int32 TotalTris = 0; + for (const FAssimpSubmeshBuffers& Chunk : Chunks) + { + TotalVerts += Chunk.Vertices.Num(); + TotalTris += Chunk.Faces.Num() / 3; + } + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("RuntimeMeshBuilder::GetTheAsyncMeshData sections=%d verts=%d tris=%d"), + Chunks.Num(), TotalVerts, TotalTris)); + } + + // Hand the chunks off to the staggered emit pump. Emitting all sections in one tick spikes + // FScene_AddPrimitive on the game thread (~300ms for 8 sections on the test asset); spreading + // them across frames keeps the per-frame cost bounded. Finalize (broadcast + EndLoadingWidget + // + bMeshBeingBuilt=false) runs after the last chunk is pushed, so listeners see a fully + // populated bounds. Component-level collision is NoCollision (ctor line 78) so per-section + // bCreateCollision stays false in the pump. + PendingMeshChunks = MoveTemp(Chunks); + PendingChunkEmitIndex = 0; + ChunkEmitStartTime = FPlatformTime::Seconds(); + + if (ChunkEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(ChunkEmitTickerHandle); + ChunkEmitTickerHandle.Reset(); + } + + if (PendingMeshChunks.Num() == 0) + { + // Nothing to emit — still run finalize so bMeshBeingBuilt/loading widget clear. + FinalizeMeshEmit(); + return; + } + + ChunkEmitTickerHandle = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateUObject(this, &ARuntimeMeshBuilder::EmitNextChunkSection), + 0.0f); +} + +bool ARuntimeMeshBuilder::EmitNextChunkSection(float /*DeltaTime*/) +{ + if (!IsValid(MobiusProceduralMeshComponent)) + { + PendingMeshChunks.Empty(); + PendingChunkEmitIndex = 0; + ChunkEmitTickerHandle.Reset(); + return false; + } + + static const TArray EmptyColors; + static const TArray EmptyTangents; + + UMobiusCustomLoggerSubsystem* StartupLogger = GetStartupLogger(); + const int32 ChunksThisTick = FMath::Max(1, SectionsEmittedPerTick); + for (int32 Pushed = 0; Pushed < ChunksThisTick && PendingChunkEmitIndex < PendingMeshChunks.Num(); ++Pushed) + { + const int32 SectionIdx = PendingChunkEmitIndex++; + FAssimpSubmeshBuffers& Chunk = PendingMeshChunks[SectionIdx]; + + const int32 ChunkVerts = Chunk.Vertices.Num(); + const int32 ChunkTris = Chunk.Faces.Num() / 3; + const double PushStart = FPlatformTime::Seconds(); + + MobiusProceduralMeshComponent->CreateMeshSection_LinearColor( + SectionIdx, + Chunk.Vertices, + Chunk.Faces, + Chunk.Normals, + Chunk.UV, + EmptyColors, + EmptyTangents, + /*bCreateCollision*/ false); + + if (MobiusMaterialInstanceDynamic) + { + MobiusProceduralMeshComponent->SetMaterial(SectionIdx, MobiusMaterialInstanceDynamic); + } + + const double PushDurationMs = (FPlatformTime::Seconds() - PushStart) * 1000.0; + if (StartupLogger) + { + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("[Building %s] CreateMeshSection section=%d verts=%d tris=%d in %.2f ms"), + *GetName(), SectionIdx, ChunkVerts, ChunkTris, PushDurationMs)); + } + UE_LOG(LogTemp, Log, + TEXT("[Building %s] CreateMeshSection section=%d verts=%d tris=%d in %.2f ms"), + *GetName(), SectionIdx, ChunkVerts, ChunkTris, PushDurationMs); + + // Free the CPU buffers for this chunk now that the component owns the data. + Chunk.Vertices.Empty(); + Chunk.Faces.Empty(); + Chunk.Normals.Empty(); + Chunk.UV.Empty(); + } + + if (PendingChunkEmitIndex >= PendingMeshChunks.Num()) { - MobiusProceduralMeshComponent->SetMaterial(0, MobiusMaterialInstanceDynamic); + FinalizeMeshEmit(); + return false; } - // Mesh has been built so we can set the flag to false + return true; +} + +void ARuntimeMeshBuilder::FinalizeMeshEmit() +{ + const int32 EmittedSections = PendingMeshChunks.Num(); + + PendingMeshChunks.Empty(); + PendingChunkEmitIndex = 0; + ChunkEmitTickerHandle.Reset(); + bMeshBeingBuilt = false; - // The origin we want to broadcast is the smallest location of the mesh bounds as the mesh generator for the heatmap - // works from left to right and bottom to top - FVector HeatmapOrigin = MobiusProceduralMeshComponent->Bounds.Origin - MobiusProceduralMeshComponent->Bounds.BoxExtent; + if (UMobiusCustomLoggerSubsystem* StartupLogger = GetStartupLogger()) + { + const double DurationMs = (FPlatformTime::Seconds() - ChunkEmitStartTime) * 1000.0; + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("RuntimeMeshBuilder::StaggeredEmit completed sections=%d in %.2f ms"), + EmittedSections, DurationMs)); + } + + if (!IsValid(MobiusProceduralMeshComponent)) + { + EndLoadingWidget(); + return; + } - // Broadcast that the mesh has been built + // The origin we want to broadcast is the smallest location of the mesh bounds as the mesh generator + // for the heatmap works from left to right and bottom to top. + const FVector HeatmapOrigin = MobiusProceduralMeshComponent->Bounds.Origin - MobiusProceduralMeshComponent->Bounds.BoxExtent; OnMeshBuilt.Broadcast(HeatmapOrigin, MobiusProceduralMeshComponent->Bounds.BoxExtent); EndLoadingWidget(); @@ -1156,7 +1360,11 @@ void ARuntimeMeshBuilder::SetMaterialOnMesh() // As the mesh may not exist we check if it does if(MobiusProceduralMeshComponent != nullptr) { - MobiusProceduralMeshComponent->SetMaterial(0, MobiusMaterialInstanceDynamic); + const int32 NumSections = MobiusProceduralMeshComponent->GetNumSections(); + for (int32 SectionIdx = 0; SectionIdx < NumSections; ++SectionIdx) + { + MobiusProceduralMeshComponent->SetMaterial(SectionIdx, MobiusMaterialInstanceDynamic); + } } else { @@ -1217,6 +1425,7 @@ void ARuntimeMeshBuilder::CreateDatasmithMaterials() PendingDatasmithMeshes.Reset(); bDatasmithMaterialSetupInProgress = false; + bHeatmapBroadcastPending = false; if (DataComps.Num() == 0) { @@ -1281,21 +1490,21 @@ void ARuntimeMeshBuilder::CreateDatasmithMaterials() return; } - // Compute origin/extents for the rest of the system (heatmap etc.) - const FVector BoundsCenter = GlobalBounds.GetCenter(); - const FVector BoundsExtent = GlobalBounds.GetExtent(); - - UE_LOG(LogTemp, Warning, TEXT("Datasmith GlobalBounds Center: %s Extent: %s"), - *BoundsCenter.ToString(), *BoundsExtent.ToString()); - - const FVector HeatmapOrigin = BoundsCenter - BoundsExtent; + // Defer OnMeshBuilt until PendingDatasmithMeshes drains (see ProcessPendingDatasmithMeshes). + // DatasmithRuntime registers components over multiple frames: at this point some + // UStaticMeshComponents may be queued but not yet IsRegistered/IsRenderStateCreated, + // which means their Bounds are zero and their render data is empty. The heatmap consumer + // walks DataComps on the broadcast, so firing now produces partial heatmaps. Bounds are + // recomputed at drain time from a fresh walk. + UE_LOG(LogTemp, Warning, TEXT("Datasmith initial GlobalBounds Center: %s Extent: %s (deferring broadcast)"), + *GlobalBounds.GetCenter().ToString(), *GlobalBounds.GetExtent().ToString()); - OnMeshBuilt.Broadcast(HeatmapOrigin, BoundsExtent); + bHeatmapBroadcastPending = true; // We now have a queue of meshes to process over multiple frames. bDatasmithMaterialSetupInProgress = true; - // Do NOT call EndLoadingWidget or BeginSpawning here; we’ll do that when the queue is empty. + // Do NOT call EndLoadingWidget or BeginSpawning here; we'll do that when the queue is empty. } TArray> ARuntimeMeshBuilder::CreateMaterialInstances(UMaterialInterface* InMaterial, const FString& MaterialPath) @@ -1565,9 +1774,63 @@ void ARuntimeMeshBuilder::ProcessPendingDatasmithMeshes(float DeltaSeconds) if (PendingDatasmithMeshes.Num() == 0) { - // We’re done – clear the flag and finish up the “import finished” flow. + // We're done – clear the flag and finish up the "import finished" flow. bDatasmithMaterialSetupInProgress = false; + // All meshes have now been visited by BuildDatasmithMaterialsForMesh, which means each + // was IsRegistered + IsRenderStateCreated when touched. Safe window to (a) recompute the + // aggregate bounds from fully-resolved components and (b) hand off to the heatmap layer. + if (bHeatmapBroadcastPending) + { + FBox FinalBounds(ForceInit); + if (RuntimeDatasmithAnchor && !RuntimeDatasmithAnchor->IsActorBeingDestroyed()) + { + auto DataComps = RuntimeDatasmithAnchor->GetComponents(); + for (auto DataComp : DataComps) + { + USceneComponent* SceneComp = Cast(DataComp); + if (!SceneComp) continue; + + TArray FinalChildren; + SceneComp->GetChildrenComponents(true, FinalChildren); + for (USceneComponent* Child : FinalChildren) + { + UStaticMeshComponent* MeshComp = Cast(Child); + if (!MeshComp || MeshComp->IsBeingDestroyed() || !MeshComp->IsRegistered()) + { + continue; + } + FinalBounds += MeshComp->Bounds.GetBox(); + } + } + } + + bHeatmapBroadcastPending = false; + + if (FinalBounds.IsValid) + { + const FVector BoundsCenter = FinalBounds.GetCenter(); + const FVector BoundsExtent = FinalBounds.GetExtent(); + const FVector HeatmapOrigin = BoundsCenter - BoundsExtent; + + UE_LOG(LogTemp, Warning, TEXT("Datasmith final GlobalBounds Center: %s Extent: %s (broadcast)"), + *BoundsCenter.ToString(), *BoundsExtent.ToString()); + + if (UMobiusCustomLoggerSubsystem* StartupLogger = GetStartupLogger()) + { + StartupLogger->EnqueueLogMessage(FString::Printf( + TEXT("RuntimeMeshBuilder::Datasmith OnMeshBuilt broadcast origin=%s extent=%s"), + *HeatmapOrigin.ToString(), *BoundsExtent.ToString())); + } + + OnMeshBuilt.Broadcast(HeatmapOrigin, BoundsExtent); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("Datasmith drain completed but no valid bounds; skipping OnMeshBuilt")); + } + } + EndLoadingWidget(); FlowCounterSpawnerComponent->BeginSpawning(); } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/MobiusCustomLoggerSubsystem.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/MobiusCustomLoggerSubsystem.cpp index 8be32d46f..4d817bc38 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/MobiusCustomLoggerSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/MobiusCustomLoggerSubsystem.cpp @@ -32,10 +32,16 @@ void UMobiusCustomLoggerSubsystem::Initialize(FSubsystemCollectionBase& Collecti { Super::Initialize(Collection); - const FString LaunchDir = FPaths::ConvertRelativePathToFull(FPaths::LaunchDir()); - LogFilePath = FPaths::Combine(LaunchDir, TEXT("MobiusCustomLog.txt")); +#if WITH_EDITOR + // Editor launches from Engine/Binaries/Win64 (often read-only under Program Files); + // route to the project's Saved/Logs directory which is always writable. + const FString LogDir = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("Logs")); +#else + // Packaged builds (including Mac .app bundles) expect the log next to the launched executable. + const FString LogDir = FPaths::ConvertRelativePathToFull(FPaths::LaunchDir()); +#endif + LogFilePath = FPaths::Combine(LogDir, TEXT("MobiusCustomLog.txt")); - // Ensure the directory exists in case LaunchDir is relative during testing. IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile(); PlatformFile.CreateDirectoryTree(*FPaths::GetPath(LogFilePath)); @@ -43,6 +49,7 @@ void UMobiusCustomLoggerSubsystem::Initialize(FSubsystemCollectionBase& Collecti FTickerDelegate::CreateUObject(this, &UMobiusCustomLoggerSubsystem::PumpLogs), 0.25f); // flush 4x per second to keep overhead tiny + UE_LOG(LogTemp, Display, TEXT("MobiusCustomLogger initialised. Writing to %s"), *LogFilePath); EnqueueLogMessage(FString::Printf(TEXT("Custom logger initialised. Writing to %s"), *LogFilePath)); } @@ -148,6 +155,8 @@ void UMobiusCustomLoggerSubsystem::FlushToDisk() if (!Handle) { + UE_LOG(LogTemp, Warning, TEXT("MobiusCustomLogger: failed to open %s for writing; %d messages dropped"), + *LogFilePath, LocalMessages.Num()); return; } diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/HeatmapPixelTextureVisualizer.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/HeatmapPixelTextureVisualizer.h index c6ea83d61..76049eed0 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/HeatmapPixelTextureVisualizer.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Actors/HeatmapPixelTextureVisualizer.h @@ -25,11 +25,26 @@ #pragma once #include "CoreMinimal.h" +#include "Containers/Ticker.h" // FTSTicker for staggered tile emit #include "GameFramework/Actor.h" #include "HeatmapPixelTextureVisualizer.generated.h" class UProceduralMeshComponent; + +/** + * Per-section buffers for a tiled heatmap grid. Each tile owns its own vertex table and emits + * its own ProcMesh section so the game-thread cost is split across frames and sections can be + * filtered independently. Boundary verts on adjacent tiles are duplicated; the grid is small + * enough that duplication cost is negligible compared to the hitch it replaces. + */ +struct FHeatmapTile +{ + TArray Verts; + TArray Tris; + TArray UVs; +}; + /** * Enum to determine the type of heatmap to render */ @@ -61,6 +76,8 @@ class MOBIUSCORE_API AHeatmapPixelTextureVisualizer : public AActor // Called when the game starts or when spawned virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + public: // Called every frame virtual void Tick(float DeltaTime) override; @@ -302,7 +319,25 @@ class MOBIUSCORE_API AHeatmapPixelTextureVisualizer : public AActor /** Floor ID of the heatmap */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Heatmap|MaterialsAndTextures") int32 FloorID = 0; - + + /** Cells per tile edge. Each tile becomes one ProcMesh section. Smaller = more sections, smaller per-section cost, more boundary-vert duplication. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Heatmap|MaterialsAndTextures", meta = (ClampMin = "4")) + int32 GridTileSize = 32; + + /** Emit tiled grid as multiple sections (true) or legacy single section 0 (false). Rollback knob. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Heatmap|MaterialsAndTextures") + bool bEnableMultiSectionBatching = true; + + /** + * Tile sections pushed per tick during staggered emit. 1 = smoothest (finishes over N frames); + * higher = faster finish at the cost of bigger per-frame FScene_AddPrimitive spike. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Heatmap|MaterialsAndTextures", meta = (ClampMin = "1")) + int32 SectionsEmittedPerTick = 1; + + /** Cached tile buffers built off the GT and drained into ProcMesh sections on the GT. */ + TArray Tiles; + #pragma endregion PUBLIC_PROPERTIES_AND_COMPONENTS private: @@ -324,8 +359,14 @@ class MOBIUSCORE_API AHeatmapPixelTextureVisualizer : public AActor * @return[FIntPoint] The number of triangles needed for the mesh in the X and Y direction */ static FIntPoint CalculateNumberOfTriangles(const FVector2D& MeshSize, const FIntPoint& TextureSize); - void CreateMeshVertexsAndUVs(FIntPoint NumTriangles, FVector2D CellSize); - void GenerateMeshTrianglesInQuadMapping(FIntPoint NumTriangles, TArray Quads); + /** + * Build a single tile's verts / tris / UVs for the cell range [TileX0, TileX1) x [TileY0, TileY1). + * Only verts referenced by kept quads are added to the tile; empty tiles return with Tris.Num()==0. + * Quad-intersect filter matches the legacy GenerateMeshTrianglesInQuadMapping behaviour. + */ + void BuildTileBuffers(int32 TileX0, int32 TileY0, int32 TileX1, int32 TileY1, + const FIntPoint& NumTriangles, const FVector2D& CellSize, + const TArray& Quads, FHeatmapTile& Out) const; /** * Method to generate the mesh vertices, UVs and triangles for the heatmap mesh. * The method performs sanity checks on the input data before spawning any @@ -343,6 +384,21 @@ class MOBIUSCORE_API AHeatmapPixelTextureVisualizer : public AActor */ TArray FindAllQuads(class ARuntimeMeshBuilder* MeshBuilder = nullptr) const; + /** Ticker pump that pushes up to SectionsEmittedPerTick tile sections per frame. Returns false when drained. */ + bool EmitNextTileSection(float DeltaTime); + + /** Runs once after the final tile is emitted — applies material across all sections and clears the timer handle. */ + void FinalizeTileEmit(); + + /** Index of the next tile in Tiles[] to push via CreateMeshSection_LinearColor. */ + int32 PendingTileEmitIndex = 0; + + /** Ticker handle for the staggered tile emit pump. Reset once all tiles are pushed. */ + FTSTicker::FDelegateHandle TileEmitTickerHandle; + + /** FPlatformTime::Seconds() sample taken when the tile emit pump started — used to log total wall time. */ + double TileEmitStartTime = 0.0; + #pragma endregion PRIVATE_METHODS #pragma region PRIVATE_PROPERTIES_AND_COMPONENTS diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/AsyncAssimpMeshLoader.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/AsyncAssimpMeshLoader.h index ad5367c70..252dfa0c5 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/AsyncAssimpMeshLoader.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/AsyncAssimpMeshLoader.h @@ -59,6 +59,30 @@ struct FPolygonWithHoles TArray> Holes; }; +/** Per-submesh buffers produced by the Assimp loader. Indices are submesh-local (no offset remap). */ +struct FAssimpSubmeshBuffers +{ + TArray Vertices; + TArray Faces; + TArray Normals; + TArray UV; +}; + +/** + * Split a submesh into chunks each capped at MaxTris triangles. + * + * Small submeshes pass through unchanged (single Out entry, zero vertex duplication). + * Oversized submeshes are partitioned in triangle order; each chunk copies only the + * vertices referenced by its own triangles, with indices remapped to the chunk-local + * vertex table. Only boundary vertices (referenced from triangles in multiple chunks) + * are duplicated across chunks. + * + * @param In Source submesh. Untouched. + * @param MaxTris Triangle cap per output chunk. Values <= 0 disable splitting. + * @param Out Chunks are appended (existing entries preserved). + */ +void SplitSubmeshByTriCap(const FAssimpSubmeshBuffers& In, int32 MaxTris, TArray& Out); + /** * @@ -102,11 +126,27 @@ class MOBIUSCORE_API FAssimpMeshLoaderRunnable final : public FRunnable FString PathToMesh; int32 SectionCount; FString ErrorMessageCode; + + /** Per-submesh buffers. One entry per aiMesh in the source scene. Consumers iterate this to emit one ProcMesh section per submesh. */ + TArray Submeshes; + + /** Flattened aggregate buffers. Retained for transitional callers that still expect monolithic data; will be removed once all callers consume Submeshes. */ TArray Vertices; TArray Faces; TArray Normals; TArray UV; TArray Tangents; + + /** Sum of per-submesh vertex counts. Useful for memory stats and parity checks against the flat buffer. */ + int32 GetTotalVertexCount() const + { + int32 Total = 0; + for (const FAssimpSubmeshBuffers& Sub : Submeshes) + { + Total += Sub.Vertices.Num(); + } + return Total; + } #pragma endregion MESH_PROPERTIES /** is the file path actually an obj string */ bool bIsWktExtension = false; diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h index 4665f1357..92d7b0454 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/BuildingGenerator/RuntimeMeshBuilder.h @@ -26,6 +26,8 @@ #include "CoreMinimal.h" #include "Actors/FlowCounter.h" +#include "AsyncAssimpMeshLoader.h" // FAssimpSubmeshBuffers for staggered emit queue +#include "Containers/Ticker.h" // FTSTicker for staggered section emit #include "GameFramework/Actor.h" #include "Interfaces/AssimpInterface.h" #include "Interfaces/ProjectMobiusInterface.h" @@ -260,6 +262,32 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "MeshGenerator|Component") class UProceduralMeshComponent* MobiusProceduralMeshComponent; + /** + * Cap per-section triangle count. Submeshes exceeding this are partitioned across extra sections + * with index-remapped vertex buffers (boundary vertices duplicated across adjacent chunks). High + * value keeps most submeshes whole; lowering it spreads game-thread CreateMeshSection cost across + * more sections (more draw calls, less hitch per section). + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MeshGenerator|Component", meta=(ClampMin="1000")) + int32 MaxTrisPerSection = 100000; + + /** + * Number of mesh sections pushed to the procedural mesh component per game-thread tick during + * staggered emit. 1 = one section per frame (smoothest, finishes over N frames); higher values + * compress the finish window at the cost of a bigger per-frame FScene_AddPrimitive spike. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MeshGenerator|Component", meta=(ClampMin="1")) + int32 SectionsEmittedPerTick = 1; + + /** + * Rollback knob for the multi-section pipeline. true = per-submesh chunks (default); + * false = legacy flatten-to-single-section-0 path. Use false to A/B against the old + * behaviour if a regression surfaces; intended to be removed once the tiled path has + * shipped a release. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "MeshGenerator|Component") + bool bEnableMultiSectionBatching = true; + /** Flow Counter Spawner - handles spawning flow counters */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "MeshGenerator|Component") class UFlowCounterSpawnerComponent* FlowCounterSpawnerComponent; @@ -336,6 +364,14 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac /** Are we currently in the middle of batched Datasmith material setup? */ bool bDatasmithMaterialSetupInProgress = false; + /** + * Datasmith registers components across frames — bounds/render state on individual + * UStaticMeshComponents only settle as they register. OnMeshBuilt can't fire until all + * queued comps have been processed or the heatmap consumer walks partial geometry. + * This flag marks "scene import done, queue still draining — fire once drained". + */ + bool bHeatmapBroadcastPending = false; + private: /** TODO: We eventually want to get the mesh material and apply our materials to it as a mask or material function to it * Material Instance Dynamic to apply to the Procedural Mesh Component after a mesh has been generated and set with @@ -356,6 +392,27 @@ class MOBIUSCORE_API ARuntimeMeshBuilder : public AActor, public IAssimpInterfac UPROPERTY(Transient) bool bIsResettingForNewLoad = false; + /** + * Mesh sections queued for staggered emit. GetTheAsyncMeshData fills this, then EmitNextChunkSection + * drains it one (or SectionsEmittedPerTick) at a time from the core ticker. Empty when idle. + */ + TArray PendingMeshChunks; + + /** Index of the next chunk in PendingMeshChunks to push via CreateMeshSection_LinearColor. */ + int32 PendingChunkEmitIndex = 0; + + /** Ticker handle for the staggered emit pump. Reset once all chunks are pushed. */ + FTSTicker::FDelegateHandle ChunkEmitTickerHandle; + + /** FPlatformTime::Seconds() sample taken when the emit pump started — used to log total wall time. */ + double ChunkEmitStartTime = 0.0; + + /** Pump that pushes up to SectionsEmittedPerTick sections per frame. Returns false once drained. */ + bool EmitNextChunkSection(float DeltaTime); + + /** Runs once after the final section is emitted: broadcast bounds, close loading widget, clear flags. */ + void FinalizeMeshEmit(); + /** Report a RuntimeMeshBuilder error through the user feedback subsystem. */ void ReportError(UObject* ContextObject, FString ErrorTitleBar, FString ErrorTitle, FString ErrorMessage, FString ErrorLocation);