diff --git a/ImportedOpenSourceAssets/MobiusIconResize.ico b/ImportedOpenSourceAssets/MobiusIconResize.ico new file mode 100644 index 000000000..c2086e13e Binary files /dev/null and b/ImportedOpenSourceAssets/MobiusIconResize.ico differ diff --git a/ImportedOpenSourceAssets/MobiusSplashResize.bmp b/ImportedOpenSourceAssets/MobiusSplashResize.bmp new file mode 100644 index 000000000..7de588af5 --- /dev/null +++ b/ImportedOpenSourceAssets/MobiusSplashResize.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29527642c7cd130eabd22abdafb99699e79a9d87fa849c08c1b52f65cbf53652 +size 1573002 diff --git a/ImportedOpenSourceAssets/MobiusSplashResize.png b/ImportedOpenSourceAssets/MobiusSplashResize.png new file mode 100644 index 000000000..272c98f1b --- /dev/null +++ b/ImportedOpenSourceAssets/MobiusSplashResize.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fd695ea6fbb2f2e2de7aac1a940a780a860ff8e242efd7818a771ce3d76f33e +size 1576091 diff --git a/UnrealFolder/ProjectMobius/Config/DefaultGame.ini b/UnrealFolder/ProjectMobius/Config/DefaultGame.ini index fa5a39982..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 @@ -20,7 +21,7 @@ Build=IfProjectHasCode BuildConfiguration=PPBC_DebugGame BuildTarget=ProjectMobius FullRebuild=True -ForDistribution=True +ForDistribution=False IncludeDebugFiles=True BlueprintNativizationMethod=Disabled bIncludeNativizedAssetsInProjectGeneration=False @@ -57,7 +58,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/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/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 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.bmp index 3a294c780..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:c5b6548063bc7af484ca06e180436b9e0474fe2fafcc424a5bd4184538babe1d -size 147594 +oid sha256:29527642c7cd130eabd22abdafb99699e79a9d87fa849c08c1b52f65cbf53652 +size 1573002 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset b/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset deleted file mode 100644 index 26e7de48b..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/EdSplash.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:68902d5d2c24849d5ec6692220b491b194d238366d680886936ffb568f33464b -size 64684 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset b/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset deleted file mode 100644 index fc1719d12..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/ProjectMobius.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d4fdfc7c8633fef7746bc28d709e01f1fe3389c6a6725ff99ba09584d58ccab8 -size 64799 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp b/UnrealFolder/ProjectMobius/Content/Splash/Splash.bmp index 3a294c780..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:c5b6548063bc7af484ca06e180436b9e0474fe2fafcc424a5bd4184538babe1d -size 147594 +oid sha256:29527642c7cd130eabd22abdafb99699e79a9d87fa849c08c1b52f65cbf53652 +size 1573002 diff --git a/UnrealFolder/ProjectMobius/Content/Splash/Splash.png b/UnrealFolder/ProjectMobius/Content/Splash/Splash.png new file mode 100644 index 000000000..272c98f1b --- /dev/null +++ b/UnrealFolder/ProjectMobius/Content/Splash/Splash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +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 98dafb1e9..000000000 --- a/UnrealFolder/ProjectMobius/Content/Splash/Splash.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:443bc8815a5cf29daf489a374b17ab475626c0823f4c3164451d259f2647fef5 -size 64638 diff --git a/UnrealFolder/ProjectMobius/ProjectMobius.uproject b/UnrealFolder/ProjectMobius/ProjectMobius.uproject index 9310350b1..4aa0068cd 100644 --- a/UnrealFolder/ProjectMobius/ProjectMobius.uproject +++ b/UnrealFolder/ProjectMobius/ProjectMobius.uproject @@ -58,6 +58,11 @@ "Name": "MobiusEditor", "Type": "Editor", "LoadingPhase": "Default" + }, + { + "Name": "ProjectMobiusTests", + "Type": "Editor", + "LoadingPhase": "Default" } ], "Plugins": [ diff --git a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp index 80cc503cf..7f513b70e 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/FlowCounter.cpp @@ -7,9 +7,9 @@ #include "Components/DeformableQuadComponent.h" #include "Kismet/GameplayStatics.h" #include "Subsystems/StatisticActorManagementSubsystem.h" -#include "Subsystems/StatisticSubsystem.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; @@ -442,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 @@ -453,6 +459,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 @@ -468,6 +477,15 @@ 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 + Super::EndPlay(EndPlayReason); } @@ -476,13 +494,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 +621,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 +700,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 +714,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(); @@ -930,6 +948,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 @@ -951,6 +986,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) @@ -1047,11 +1089,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 +1110,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 +1123,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 +1198,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/Actors/HeatmapPixelTextureVisualizer.cpp b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Actors/HeatmapPixelTextureVisualizer.cpp index 79717d8a5..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,21 +784,36 @@ 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(); 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); + 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, @@ -856,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, @@ -971,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); @@ -1037,37 +1067,109 @@ 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]() + // 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, LocalTileSize]() { - // 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); - - // Generate the vertices and UVs - CreateMeshVertexsAndUVs(NumTriangles, CellSize); - - // Generate the Triangles for this square - GenerateMeshTrianglesInQuadMapping(NumTriangles, Quads); - - + const TArray Quads = Self->FindAllQuads(MeshBuilder); + + // 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); + + 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); + } }, - [this] + [WeakThis] { - AsyncTask(ENamedThreads::GameThread, [this]() + AsyncTask(ENamedThreads::GameThread, [WeakThis]() { - - // Generate the mesh section - RuntimeHeatmapMeshComponent->CreateMeshSection_LinearColor(0, MeshVertices, MeshTriangles, TArray(), MeshUVs, TArray(), TArray(), false); - + AHeatmapPixelTextureVisualizer* Self = WeakThis.Get(); + if (!Self || !IsValid(Self->RuntimeHeatmapMeshComponent)) return; + + // 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); }); }); @@ -1085,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 @@ -1166,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 31ca7b441..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) { @@ -140,11 +200,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; } } @@ -901,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(); @@ -908,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) { @@ -919,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()) { @@ -940,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); } } @@ -953,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 b9b319ab3..97b4e5c50 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() : @@ -63,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; @@ -146,11 +153,121 @@ 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) { + // 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. + 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); } @@ -204,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) @@ -318,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() @@ -360,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 @@ -388,13 +539,6 @@ void ARuntimeMeshBuilder::UpdateMeshFileName() // Make sure no residual data of the procedural mesh comp exists ResetMeshCollisionAndPhysics(); - if (RuntimeDatasmithAnchor) - { - auto ActorToDestroy = RuntimeDatasmithAnchor; - ActorToDestroy->Destroy(); - RuntimeDatasmithAnchor = nullptr; - } - // Double-flush queues in case async work enqueued new items during teardown PendingCollisionEnable.Reset(); PendingDatasmithMeshes.Reset(); @@ -405,6 +549,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")) { @@ -424,8 +629,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,41 +666,39 @@ 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; + } }); }); - - - - - // 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 { @@ -501,32 +708,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) @@ -570,13 +760,27 @@ 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(); - AsyncTask(ENamedThreads::GameThread, [&ExistingRunnable] + // 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] { - // Delete the existing runnable on the game thread delete ExistingRunnable; }); } @@ -621,53 +825,217 @@ void ARuntimeMeshBuilder::GetTheAsyncMeshData() MobiusProceduralMeshComponent->SetCollisionResponseToAllChannels(ECR_Block); MobiusProceduralMeshComponent->SetSimulatePhysics(false); + // 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); - // 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*/); + // 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; - // The loader is no longer needed so we can stop the thread - AsyncAssimpLoader->MeshLoaderRunnable->Stop(); + // 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(); - // nullptr the runnable to free up memory - AsyncAssimpLoader->MeshLoaderRunnable = nullptr; + // 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(); + ExistingRunnable->UV.Empty(); + ExistingRunnable->Tangents.Empty(); - // if the material property is set then we want to apply our material to the mesh - if(MobiusMaterialInstanceDynamic != nullptr) + AsyncTask(ENamedThreads::GameThread, [ExistingRunnable] + { + delete ExistingRunnable; + }); + } + + // 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) { - MobiusProceduralMeshComponent->SetMaterial(0, MobiusMaterialInstanceDynamic); + 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)); } - // Mesh has been built so we can set the flag to false - bMeshBeingBuilt = false; + 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)); + } - // 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; + // 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(); - // Broadcast that the mesh has been built - OnMeshBuilt.Broadcast(HeatmapOrigin, MobiusProceduralMeshComponent->Bounds.BoxExtent); + if (ChunkEmitTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(ChunkEmitTickerHandle); + ChunkEmitTickerHandle.Reset(); + } - // check if runnable is null and if not then delete it - if (auto* ExistingRunnable = AsyncAssimpLoader->MeshLoaderRunnable) + if (PendingMeshChunks.Num() == 0) { - AsyncAssimpLoader->MeshLoaderRunnable = nullptr; + // Nothing to emit — still run finalize so bMeshBeingBuilt/loading widget clear. + FinalizeMeshEmit(); + return; + } - // Stop the existing runnable - ExistingRunnable->Stop(); - ExistingRunnable->Exit(); + ChunkEmitTickerHandle = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateUObject(this, &ARuntimeMeshBuilder::EmitNextChunkSection), + 0.0f); +} - AsyncTask(ENamedThreads::GameThread, [&ExistingRunnable] +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) { - // Delete the existing runnable on the game thread - delete ExistingRunnable; - }); + 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()) + { + FinalizeMeshEmit(); + return false; + } + + return true; +} + +void ARuntimeMeshBuilder::FinalizeMeshEmit() +{ + const int32 EmittedSections = PendingMeshChunks.Num(); + + PendingMeshChunks.Empty(); + PendingChunkEmitIndex = 0; + ChunkEmitTickerHandle.Reset(); + + bMeshBeingBuilt = false; + + 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; + } + + // 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(); } @@ -992,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 { @@ -1053,6 +1425,7 @@ void ARuntimeMeshBuilder::CreateDatasmithMaterials() PendingDatasmithMeshes.Reset(); bDatasmithMaterialSetupInProgress = false; + bHeatmapBroadcastPending = false; if (DataComps.Num() == 0) { @@ -1117,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) @@ -1401,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(); } @@ -1424,8 +1851,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/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/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/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..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); + } }); }); @@ -542,34 +546,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 +588,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/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/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 5460302c3..e26248f65 100644 --- a/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Private/Subsystems/StatisticSubsystem.cpp @@ -4,9 +4,8 @@ #include "Subsystems/StatisticSubsystem.h" #include "Actors/FlowCounter.h" -#include "Components/BoxComponent.h" -#include "Kismet/GameplayStatics.h" #include "Subsystems/StatisticActorManagementSubsystem.h" +#include "Util/MemoryTraceHelper.h" UStatisticSubsystem::UStatisticSubsystem() { @@ -17,7 +16,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 @@ -29,13 +28,23 @@ 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 if (UStatisticActorManagementSubsystem* StatisticActorManagementSubsystem = GetWorld()->GetSubsystem()) { StatisticActorManagementSubsystem->OnFlowCountersChanged.Unbind(); } + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("StatSub_Deinit_End")).LogDelta(SnapDeinit); +#endif } void UStatisticSubsystem::OnWorldBeginPlay(UWorld& InWorld) @@ -82,19 +91,21 @@ FAgentMeshViewer UStatisticSubsystem::GetSelectedAgentInfoMeshData() FAgentMeshViewer UStatisticSubsystem::GetHoveredAgentInfoMeshData() { - return MoveTemp(HoveredAgentData); + return HoveredAgentData; } void UStatisticSubsystem::UpdateFlowCounters() { if (!GetWorld()){return;} - + UStatisticActorManagementSubsystem* StatisticActorManagementSubsystem = GetWorld()->GetSubsystem(); 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; } } @@ -105,12 +116,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 +142,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 +166,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 +173,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 +181,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 +188,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); } @@ -222,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) @@ -233,6 +212,29 @@ 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::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) @@ -319,4 +321,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/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 42039eeef..92d7b0454 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. */ @@ -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" @@ -39,7 +41,7 @@ class UMaterialInstanceDynamic; class UMaterialInterface; class UMaterialInstanceConstant; class UMaterial; -class UTexture; +class UTexture; class UMobiusCustomLoggerSubsystem; /** Delegates */ @@ -74,7 +76,7 @@ USTRUCT() struct FPendingDatasmithMesh { GENERATED_BODY() - + UPROPERTY() TWeakObjectPtr Mesh; @@ -88,15 +90,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 +108,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") @@ -131,9 +133,42 @@ 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; + /** - * Function to update the Mesh Data via the Async Assimp - * + * 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 + * * @param PathToMesh The Path to the Mesh to load */ UFUNCTION(BlueprintCallable, Category = "MeshGenerator|UpdateMethods") @@ -204,21 +239,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 @@ -226,7 +261,33 @@ 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; - + + /** + * 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; @@ -259,17 +320,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 */ /***/ @@ -277,7 +338,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; @@ -288,7 +349,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; @@ -302,7 +363,15 @@ 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 @@ -310,7 +379,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; @@ -318,17 +387,38 @@ 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; + /** + * 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); /** 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 @@ -341,7 +431,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") 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/MobiusCore/Public/Util/MemoryTraceHelper.h b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/MemoryTraceHelper.h new file mode 100644 index 000000000..a3c624550 --- /dev/null +++ b/UnrealFolder/ProjectMobius/Source/MobiusCore/Public/Util/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" + +MOBIUSCORE_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/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/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/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp b/UnrealFolder/ProjectMobius/Source/MobiusWidgets/Private/UI/InWorld/AgentInfoDisplay.cpp index f5031ad35..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() @@ -54,12 +46,21 @@ void UAgentInfoDisplay::SynchronizeProperties() void UAgentInfoDisplay::ReleaseSlateResources(bool bReleaseChildren) { - Super::ReleaseSlateResources(bReleaseChildren); + if (UWorld* World = GetWorld()) + { + if (UStatisticSubsystem* StatSub = World->GetSubsystem()) + { + StatSub->OnSelectedAgentInfoChanged.RemoveAll(this); + } + } HoverWidget.Reset(); FollowIndicatorWidget.Reset(); + + Super::ReleaseSlateResources(bReleaseChildren); } + TSharedRef UAgentInfoDisplay::RebuildWidget() { // Create the children @@ -68,7 +69,7 @@ TSharedRef UAgentInfoDisplay::RebuildWidget() FollowIndicatorWidget = SNew(SAgentFollowIndicator, *this); - + // Create the overlay TSharedRef Overlay = SNew(SOverlay) @@ -83,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/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/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/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/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/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 4daf680a1..2d632b0d2 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. */ @@ -42,6 +42,7 @@ #include "MassAI/SubSystems/MassRepresentation/MRS_RepresentationSubsystem.h" #include "Subsystems/LoadingSubsystem.h" #include "Subsystems/MobiusUserFeedbackSubsystem.h" +#include "Util/MemoryTraceHelper.h" namespace { @@ -101,7 +102,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 @@ -112,7 +113,7 @@ void UAgentDataSubsystem::Initialize(FSubsystemCollectionBase& Collection) // Get the Current Data File set on the instance // JSONDataFile = GameInst->GetPedestrianDataFilePath(); // GetJSONDataFile(JSONDataFile); - + } else { @@ -134,7 +135,7 @@ void UAgentDataSubsystem::Initialize(FSubsystemCollectionBase& Collection) // CalculateMaxEntitiesPermitted(); // } } - + } void UAgentDataSubsystem::Deinitialize() @@ -142,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(); @@ -151,17 +153,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)) { @@ -186,107 +188,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,20 +200,19 @@ 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 entity data cached (moved out of runnable before it was torn down) + if (CachedEntityData.Num() > 0) { - const TArray& Entities = JsonDataRunnable->Hdf5Data.Entities; - if (!Entities.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 = Entities[Index]; + const FHdf5EntityData& Entity = CachedEntityData[Index]; EntityInfoFragToUpdate.EntityID = Entity.Id; EntityInfoFragToUpdate.EntityName = Entity.Name; EntityInfoFragToUpdate.EntitySimTimeS = FString::SanitizeFloat(Entity.SimTimeS); @@ -322,30 +222,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 @@ -364,56 +244,27 @@ 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 entity data cached (moved out of runnable before it was torn down) + if (CachedEntityData.Num() > 0) { - const TArray& Entities = JsonDataRunnable->Hdf5Data.Entities; - if (!Entities.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 = Entities[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 @@ -460,6 +311,31 @@ void UAgentDataSubsystem::UpdateMaxAgentCount(int32 NewMaxAgentCount) UE_LOG(LogTemp, Warning, TEXT("New Max Agent Count: %d"), MaxAgents); } +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. + 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)) @@ -512,8 +388,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); } @@ -550,10 +426,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! { @@ -592,7 +468,7 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeJSONFile() TSharedRef> JsonReader = TJsonReaderFactory::Create(SimulationDataFile); JSONObject.Reset(); - + // Deserialize JSON Data bool bDeserializeSuccess = FJsonSerializer::Deserialize(JsonReader, JSONObject); @@ -606,7 +482,7 @@ bool FProcessSimulationDataRunnable::LoadAndDeserializeJSONFile() bShouldStop = true; return false; } - + // if successful we can set the simulation file type SimulationFileType = ESimulationFileType::ESFT_JSON; @@ -659,6 +535,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)) @@ -672,6 +552,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()); } @@ -685,9 +568,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(), @@ -718,7 +608,7 @@ void FProcessSimulationDataRunnable::ProcessMetadata(bool& bCalculateTimeBetween { bCalculateTimeBetweenSteps = true; bCalculateMaxTime = true; - + // Check what file we are working with switch (SimulationFileType) { @@ -799,6 +689,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) @@ -814,7 +725,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 { @@ -833,10 +744,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; @@ -855,18 +766,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) @@ -962,7 +873,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()); @@ -1118,7 +1029,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; @@ -1191,7 +1102,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; } @@ -1347,7 +1258,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; @@ -1425,7 +1336,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) { @@ -1482,7 +1393,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) { @@ -1543,7 +1454,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) { @@ -1588,7 +1499,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) { @@ -1620,7 +1531,7 @@ void FProcessSimulationDataRunnable::FinalizeProgress() return; } UAgentDataSubsystem* Subsys = OwnerSubsystem.Get(); - + // Perform Animation Preprocessing data here // Broadcast the current percentage of the data loaded if (Subsys) @@ -1637,7 +1548,7 @@ void FProcessSimulationDataRunnable::FinalizeProgress() { Subsys->LoadingTaskQueue.Enqueue(TEXT("Calculating Smoothed Step Movement Brackets...")); } - + CalcSmoothedStepMovementBrackets(AgentDataArray); // let the thread sleep for 0.5 second @@ -1666,12 +1577,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...")); } @@ -1683,16 +1600,20 @@ uint32 FProcessSimulationDataRunnable:: Run() return 0; } +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot::Take(TEXT("Run_AfterDeserialize")).LogDelta(SnapRunStart); +#endif + bool bCalculateTimeBetweenSteps = true; bool bCalculateMaxTime = true; - + if (Subsys) { Subsys->LoadingTaskQueue.Enqueue(TEXT("Processing Simulation Metadata...")); } ProcessMetadata(bCalculateTimeBetweenSteps, bCalculateMaxTime); - + if (Subsys) { Subsys->MaxAgentsQueue.Enqueue(MaxAgents); @@ -1718,6 +1639,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; @@ -1753,9 +1678,36 @@ 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::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 + FMemory::Trim(); // hint allocator to return freed JSON pages to OS + } + // 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 } @@ -1765,6 +1717,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 @@ -1776,24 +1733,23 @@ void FProcessSimulationDataRunnable::Exit() if (HDF5SimulationReader.IsOpen()) { HDF5SimulationReader.CloseFile(); - } - Hdf5Data.Entities.Empty(); - Hdf5Data.Entities.Shrink(); + // 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, 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; - 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(); - AgentDataArray.Empty(); AgentDataArray.Shrink(); @@ -1803,10 +1759,11 @@ 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 + FMobiusMemSnapshot::Take(TEXT("Exit_Complete")).LogDelta(SnapExitStart); +#endif } TArray FProcessSimulationDataRunnable::GetMovementSamples(int32 AgentID) @@ -1814,18 +1771,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) @@ -1844,8 +1801,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); @@ -1965,13 +1922,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 d26fefa79..83baecd8c 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. */ @@ -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" @@ -50,6 +52,7 @@ #include "MassAI/Fragments/EntityTags/PedestrianCollisionTags.h" #include "MassAI/Fragments/SharedFragments/RepresentationFragments/AgentNiagaraDataFrag.h" #include "MassAI/SubSystems/PedestrianSignalSubsystem.h" +#include "Util/MemoryTraceHelper.h" #include "Subsystems/StatisticSubsystem.h" class UTimeDilationSubSystem; @@ -67,14 +70,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 @@ -100,14 +103,17 @@ 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(); } void UMassEntitySpawnSubsystem::OnWorldBeginPlay(UWorld& InWorld) { Super::OnWorldBeginPlay(InWorld); - + // Ensure rep actor exists BEFORE Mass creates entities if (UWorld* World = GetWorld()) { @@ -124,7 +130,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()) @@ -136,8 +142,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 } } @@ -160,7 +173,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) @@ -199,9 +212,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 } } @@ -209,21 +245,35 @@ void UMassEntitySpawnSubsystem::AgentDataRunnableCleanup(TUniquePtrStop(); - - // 2) Join/Exit on calling thread (don’t bounce to GT). Ensure the runnable sets a “finished” flag. - ToKill->Exit(); +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapCleanupStart = FMobiusMemSnapshot::Take(TEXT("RunnableCleanup_Start")); + SnapCleanupStart.LogAbsolute(); +#endif - // 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); +#endif } FMassArchetypeHandle UMassEntitySpawnSubsystem::CreatePedestrianArchetype() @@ -233,9 +283,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 +298,44 @@ 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; - + +#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 // capsule components are cleared and marked for destruction so that we don't have memory leaks for (auto& EntityHandle : SpawnedEntityPedestrianHandles) @@ -271,21 +346,210 @@ void UMassEntitySpawnSubsystem::CreatePedestrianTemplateData() Fragment.Capsule->DestroyComponent(); } } - + +#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 + + // 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(); - + // 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); +#if !UE_BUILD_SHIPPING + { + FMobiusMemSnapshot S = FMobiusMemSnapshot::Take(TEXT("FileSwitch_AfterFirstGC")); + S.LogDelta(SnapPrev); + SnapPrev = S; + } +#endif + + // 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(); + +#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 + // references to the fragment in lingering chunks, which would otherwise + // prevent the ~900MB of TMap/TArray memory from being reclaimed. + if (FSimulationFragment* Frag = SharedSimulationFragment.GetPtr()) + { + // Reset the TSharedPtr — frees the 4 GB TMap independently of the Mass archetype + // 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 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(); } @@ -293,25 +557,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) { @@ -331,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 @@ -346,43 +612,53 @@ 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")); - + +#if !UE_BUILD_SHIPPING + FMobiusMemSnapshot SnapBuildStart = FMobiusMemSnapshot::Take(TEXT("BuildFrag_Start")); + SnapBuildStart.LogAbsolute(); +#endif + // 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); } 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 entity metadata before the runnable is torn down. + // 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")); @@ -394,20 +670,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; - } - // Get Time Dilation from the ProjectMobius Game Instance UTimeDilationSubSystem* TimeDilationSubSystem = GetWorld()->GetSubsystem(); @@ -417,16 +685,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,9 +705,15 @@ void UMassEntitySpawnSubsystem::BuildPedestrianMovementFragmentData() // Broadcast that the pedestrian data is ready to spawn 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 @@ -468,5 +742,5 @@ void UMassEntitySpawnSubsystem::BuildPedestrianRepresentationFragmentData() // Add the shared fragment to the build context PedestrianTemplateData.AddSharedFragment(NiagaraSharedDataFrag); } - + } 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", }); 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; 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/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h b/UnrealFolder/ProjectMobius/Source/ProjectMobius/Public/MassAI/SubSystems/AgentDataSubsystem.h index 9cecb2c44..d9a6ba3d0 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 @@ -131,7 +117,15 @@ class PROJECTMOBIUS_API UAgentDataSubsystem : public UTickableWorldSubsystem, pu */ 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 @@ -158,9 +152,14 @@ class PROJECTMOBIUS_API UAgentDataSubsystem : public UTickableWorldSubsystem, pu public: /** Pointer to the FRunnable JSON Parser */ TUniquePtr JsonDataRunnable; - - /** JSON Object */ - TSharedPtr JSONObject; + + /** + * 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 CachedEntityData; /** Delegate to broadcast when the simulation data has finished loading */ UPROPERTY() 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; }; 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/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..b50123f28 --- /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 "Util/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..967ccf4df --- /dev/null +++ b/UnrealFolder/ProjectMobius/Source/ProjectMobiusTests/ProjectMobiusTests.Build.cs @@ -0,0 +1,27 @@ +// 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", + "AutomationController", + "ProjectMobius", + "Hdf5DataPlugin", + "MassEntity", + "StructUtils", + "MassCommon", + "MassSpawner", + "Json", + "JsonUtilities", + "MobiusCore", + }); + } +}