Files
vertical-partition-system-p…/Source/VerticalPartitionEditor/Private/VerticalPartitionBuilder.cpp
Bonchellon 04ad6d3f25 Initial commit: Vertical Partition Streaming plugin (UE 5.7)
Bounded vertical route partition / streaming for handcrafted spiral climb maps
(Only Up / Only Climb). Not World Partition, not Level Instances as core arch.

- Z-cell segmentation + optional XY subcells, editor builder + commandlet
- NonDestructive visibility backend + streaming-level backend
- Offline HLOD proxies: MergedMesh / SimplifiedProxy (ProxyLOD)
- Universal Level Instance + Packed Level Actor + HISM support (flatten+merge)
- Vertical-aware runtime streamer (full/preload/HLOD/unload, hysteresis,
  velocity prediction, fall-risk), WP-style debug viz + vp.* console
- Safe-by-default: only static decor streams; sky/lights/gameplay stay persistent

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:42:41 +03:00

1006 lines
33 KiB
C++

// Copyright IHY. Vertical Partition Streaming plugin (editor module).
#include "VerticalPartitionBuilder.h"
#include "VerticalPartitionVolume.h"
#include "VerticalPartitionDescriptor.h"
#include "VerticalPartitionManager.h"
#include "VerticalPartitionStatics.h"
#include "VerticalPartitionSettings.h"
#include "VerticalPartitionLog.h"
#include "EngineUtils.h"
#include "Engine/World.h"
#include "Engine/Level.h"
#include "Engine/StaticMesh.h"
#include "Engine/StaticMeshActor.h"
#include "Engine/BlueprintGeneratedClass.h"
#include "GameFramework/Actor.h"
#include "Components/StaticMeshComponent.h"
#include "Components/PrimitiveComponent.h"
#include "Components/LightComponentBase.h"
#include "Components/AudioComponent.h"
#include "Components/SkyLightComponent.h"
#include "Components/SkyAtmosphereComponent.h"
#include "Components/ExponentialHeightFogComponent.h"
#include "Components/PostProcessComponent.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/Package.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "Misc/FileHelper.h"
#include "Misc/DateTime.h"
#include "HAL/FileManager.h"
#include "MeshMerge/MeshMergingSettings.h"
#include "MeshMerge/MeshProxySettings.h"
#include "IMeshMergeUtilities.h"
#include "MeshMergeModule.h"
#include "IMeshReductionInterfaces.h"
#include "LevelInstance/LevelInstanceInterface.h"
#include "LevelInstance/LevelInstanceSubsystem.h"
#include "PackedLevelActor/PackedLevelActor.h"
#include "Misc/Guid.h"
#if WITH_EDITOR
#include "Editor.h"
#endif
// ---------------------------------------------------------------------------
// Analyze
// ---------------------------------------------------------------------------
FVerticalPartitionBuildReport UVerticalPartitionBuilder::AnalyzeCurrentLevel(AVerticalPartitionVolume* Volume)
{
FVerticalPartitionBuildReport Report;
if (!Volume || !Volume->GetWorld())
{
FVerticalPartitionError E;
E.Type = EVerticalWarningType::NoVolume;
E.Message = TEXT("No valid AVerticalPartitionVolume / world to analyze.");
Report.Errors.Add(E);
Report.ErrorCount = 1;
return Report;
}
const FVerticalPartitionSettings& S = Volume->Settings;
const FBox Bounds = Volume->GetVolumeBounds();
TArray<FVerticalActorAssignment> Assignments;
GatherActors(Volume, Bounds, Assignments, Report);
TMap<FVerticalCellId, int32> CellCounts;
for (const FVerticalActorAssignment& A : Assignments)
{
if (A.bExcluded)
{
continue;
}
ClassifyActorWarnings(A, S, Report);
CellCounts.FindOrAdd(A.Cell)++;
}
Report.GeneratedCells = CellCounts.Num();
for (const TPair<FVerticalCellId, int32>& Pair : CellCounts)
{
if (Pair.Value > Report.LargestCellActorCount)
{
Report.LargestCellActorCount = Pair.Value;
Report.LargestCell = Pair.Key;
}
if (Pair.Value > 2000)
{
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::CellTooManyActors;
W.Cell = Pair.Key;
W.Message = FString::Printf(TEXT("Cell %s has %d actors."), *Pair.Key.ToString(), Pair.Value);
Report.Warnings.Add(W);
}
}
Report.WarningCount = Report.Warnings.Num();
Report.ErrorCount = Report.Errors.Num();
return Report;
}
// An actor is safe to STREAM (hide/show + proxy) only if it is pure static decor.
// Anything that lights, sounds, moves, replicates, or is a Level Instance container
// must stay in the persistent level — hiding it would break the scene (e.g. the sky).
static bool IsStreamableStaticDecor(AActor* A)
{
if (!A)
{
return false;
}
// Must actually own static geometry. Level Instances have no mesh components on the
// actor itself, so they fail here and are kept persistent.
bool bHasStatic = false;
TArray<UStaticMeshComponent*> SM;
A->GetComponents(SM);
for (UStaticMeshComponent* C : SM)
{
if (C && C->GetStaticMesh() && C->Mobility == EComponentMobility::Static)
{
bHasStatic = true;
break;
}
}
if (!bHasStatic)
{
return false;
}
// Disqualifying components — never hide lighting / sky / audio.
if (A->FindComponentByClass<ULightComponentBase>()) { return false; }
if (A->FindComponentByClass<USkyLightComponent>()) { return false; }
if (A->FindComponentByClass<USkyAtmosphereComponent>()) { return false; }
if (A->FindComponentByClass<UExponentialHeightFogComponent>()) { return false; }
if (A->FindComponentByClass<UPostProcessComponent>()) { return false; }
if (A->FindComponentByClass<UAudioComponent>()) { return false; }
// Name-based sweep for FX / capture / decal (avoids pulling extra modules). Erring
// toward "keep persistent" is always safe; it just streams a little less.
for (UActorComponent* Comp : A->GetComponents())
{
if (!Comp) { continue; }
const FString CN = Comp->GetClass()->GetName();
if (CN.Contains(TEXT("Light")) || CN.Contains(TEXT("Fog")) || CN.Contains(TEXT("Sky"))
|| CN.Contains(TEXT("Particle")) || CN.Contains(TEXT("Niagara"))
|| CN.Contains(TEXT("ReflectionCapture")) || CN.Contains(TEXT("Decal"))
|| CN.Contains(TEXT("PostProcess")))
{
return false;
}
}
// Gameplay / dynamic signals.
if (A->GetIsReplicated()) { return false; }
if (USceneComponent* Root = A->GetRootComponent())
{
if (Root->Mobility == EComponentMobility::Movable) { return false; }
}
TArray<UPrimitiveComponent*> Prims;
A->GetComponents(Prims);
for (UPrimitiveComponent* P : Prims)
{
if (P && P->IsSimulatingPhysics()) { return false; }
}
return true;
}
// Flatten a streamable unit to its renderable leaf actors:
// - a normal actor or a Packed Level Actor -> itself (its own static / ISM / HISM comps),
// - a plain Level Instance -> its loaded child actors, recursively (nested LIs flattened,
// Packed Level Actors kept whole). Lights / gameplay leaves are returned too; the caller
// filters with IsStreamableStaticDecor so only static decor is actually streamed.
static void CollectStreamLeafActors(AActor* Unit, UWorld* World, TArray<AActor*>& Out, int32 Depth = 0)
{
if (!Unit || Depth > 8)
{
return;
}
// A Packed Level Actor bakes its source level into owned ISM/HISM components, so it is a
// leaf (do NOT recurse into its source world). A plain Level Instance streams a real level
// whose actors we recurse into.
const bool bIsPacked = Unit->IsA(APackedLevelActor::StaticClass());
ILevelInstanceInterface* LI = bIsPacked ? nullptr : Cast<ILevelInstanceInterface>(Unit);
if (LI && World)
{
if (ULevelInstanceSubsystem* Sub = World->GetSubsystem<ULevelInstanceSubsystem>())
{
if (LI->IsLoaded())
{
Sub->ForEachActorInLevelInstance(LI, [&](AActor* Child) -> bool
{
if (Child && Child != Unit)
{
CollectStreamLeafActors(Child, World, Out, Depth + 1);
}
return true; // keep iterating
});
return;
}
}
}
Out.AddUnique(Unit);
}
void UVerticalPartitionBuilder::GatherActors(AVerticalPartitionVolume* Volume, const FBox& VolumeBounds,
TArray<FVerticalActorAssignment>& OutAssignments, FVerticalPartitionBuildReport& Report)
{
UWorld* World = Volume->GetWorld();
const FVerticalPartitionSettings& S = Volume->Settings;
auto IsExcludedClass = [&S](AActor* A) -> bool
{
for (const TSoftClassPtr<AActor>& Soft : S.ExcludeClasses)
{
if (UClass* C = Soft.LoadSynchronous())
{
if (A->IsA(C)) { return true; }
}
}
if (S.IncludeOnlyClasses.Num() > 0)
{
bool bIncluded = false;
for (const TSoftClassPtr<AActor>& Soft : S.IncludeOnlyClasses)
{
if (UClass* C = Soft.LoadSynchronous())
{
if (A->IsA(C)) { bIncluded = true; break; }
}
}
if (!bIncluded) { return true; }
}
return false;
};
ULevelInstanceSubsystem* LISub = World ? World->GetSubsystem<ULevelInstanceSubsystem>() : nullptr;
for (TActorIterator<AActor> It(World); It; ++It)
{
AActor* A = *It;
if (!A || A == Volume || A->IsA<AVerticalPartitionManager>() || A->IsA<AVerticalPartitionVolume>())
{
continue;
}
if (A->IsEditorOnly())
{
continue;
}
// Skip actors that are the CONTENT of a Level Instance — they are represented by
// (and streamed through) their owning LI unit, so they must not be scanned twice.
if (LISub && A->GetLevel() && LISub->GetOwningLevelInstance(A->GetLevel()))
{
continue;
}
++Report.TotalActorsScanned;
FBox B = A->GetComponentsBoundingBox(true);
if (!B.IsValid)
{
const FVector P = A->GetActorLocation();
B = FBox(P - FVector(50.f), P + FVector(50.f));
}
const bool bInside = !S.bIncludeOnlyActorsInsideVolume || VolumeBounds.Intersect(B);
if (!bInside)
{
continue;
}
++Report.ActorsInsideVolume;
FVerticalActorAssignment Asg;
Asg.Actor = A;
Asg.Bounds = B;
if (IsExcludedClass(A))
{
Asg.bExcluded = true;
++Report.ActorsExcluded;
OutAssignments.Add(Asg);
continue;
}
// Flatten this unit (normal actor / Packed Level Actor / Level Instance) to its
// streamable static-decor leaf actors. Lights / gameplay leaves are dropped here and
// stay persistent + untouched. This is what makes LI + PLA support universal.
const bool bIsLevelInstance = (Cast<ILevelInstanceInterface>(A) != nullptr);
const bool bIsPackedLI = A->IsA(APackedLevelActor::StaticClass());
if (bIsLevelInstance && !bIsPackedLI && !S.bStreamLevelInstances)
{
Asg.bExcluded = true;
++Report.ActorsExcluded;
OutAssignments.Add(Asg);
continue;
}
TArray<AActor*> RawLeaves;
CollectStreamLeafActors(A, World, RawLeaves);
FBox UnitBounds(ForceInit);
for (AActor* Leaf : RawLeaves)
{
if (S.bStreamOnlyStaticDecor && !IsStreamableStaticDecor(Leaf))
{
continue; // keep lights/audio/dynamic content persistent
}
Asg.LeafActors.Add(Leaf);
UnitBounds += Leaf->GetComponentsBoundingBox(true);
}
if (Asg.LeafActors.Num() == 0)
{
// Nothing safe to stream from this unit -> keep persistent.
if (bIsLevelInstance)
{
if (ILevelInstanceInterface* LI = Cast<ILevelInstanceInterface>(A))
{
if (!LI->IsLoaded())
{
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::ActorNeedsManualReview;
W.Actor = FSoftObjectPath(A);
W.Message = FString::Printf(TEXT("Level Instance '%s' is not loaded — load it in the editor and rebuild to stream / HLOD it."), *A->GetName());
Report.Warnings.Add(W);
}
}
}
Asg.bExcluded = true;
++Report.ActorsExcluded;
OutAssignments.Add(Asg);
continue;
}
// Assign by the actual streamed geometry's bounds (handles LIs whose actor pivot
// differs from their contents).
if (UnitBounds.IsValid)
{
B = UnitBounds;
Asg.Bounds = UnitBounds;
}
if (S.bUseActorBoundsInsteadOfOrigin)
{
Asg.Cell = UVerticalPartitionStatics::GetCellIdFromBounds(B, VolumeBounds, S, Asg.bCrossesMultiple);
}
else
{
Asg.Cell = UVerticalPartitionStatics::GetCellIdFromPoint(B.GetCenter(), VolumeBounds, S);
}
// Large-actor handling.
const float ZExtent = B.Max.Z - B.Min.Z;
const bool bLarge = (S.CellHeight > KINDA_SMALL_NUMBER) && (ZExtent > S.CellHeight * S.LargeActorCellSpan);
if (bLarge)
{
switch (S.LargeActorPolicy)
{
case ELargeActorPolicy::KeepInPersistentLevel:
case ELargeActorPolicy::DuplicateAsHLODOnly:
Asg.bLargeKeepPersistent = true;
break;
case ELargeActorPolicy::ExcludeFromPartition:
Asg.bExcluded = true;
++Report.ActorsExcluded;
break;
case ELargeActorPolicy::SplitNotSupportedWarning:
case ELargeActorPolicy::AssignToDominantCell:
default:
break; // assigned to dominant (centre) cell already
}
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::ActorTooLarge;
W.Actor = FSoftObjectPath(A);
W.Cell = Asg.Cell;
W.Message = FString::Printf(TEXT("%s spans %.0f cm (> %.1f cells)."), *A->GetName(), ZExtent, S.LargeActorCellSpan);
Report.Warnings.Add(W);
}
OutAssignments.Add(Asg);
}
}
void UVerticalPartitionBuilder::ClassifyActorWarnings(const FVerticalActorAssignment& A, const FVerticalPartitionSettings& /*S*/,
FVerticalPartitionBuildReport& Report)
{
AActor* Actor = A.Actor.Get();
if (!Actor)
{
return;
}
const FSoftObjectPath Path(Actor);
auto AddW = [&](EVerticalWarningType Type, const FString& Msg)
{
FVerticalPartitionWarning W;
W.Type = Type;
W.Actor = Path;
W.Cell = A.Cell;
W.Message = Msg;
Report.Warnings.Add(W);
};
if (A.bCrossesMultiple)
{
AddW(EVerticalWarningType::ActorCrossesMultipleCells,
FString::Printf(TEXT("%s straddles a cell boundary; assigned to dominant cell %s."), *Actor->GetName(), *A.Cell.ToString()));
}
// Physics simulation.
bool bPhysics = false;
bool bMovable = false;
bool bLight = false;
bool bAudio = false;
TArray<UPrimitiveComponent*> Prims;
Actor->GetComponents(Prims);
for (UPrimitiveComponent* P : Prims)
{
if (P && P->IsSimulatingPhysics()) { bPhysics = true; }
if (P && P->Mobility == EComponentMobility::Movable) { bMovable = true; }
}
if (USceneComponent* Root = Actor->GetRootComponent())
{
if (Root->Mobility == EComponentMobility::Movable) { bMovable = true; }
}
TArray<ULightComponentBase*> Lights;
Actor->GetComponents(Lights);
bLight = Lights.Num() > 0;
TArray<UAudioComponent*> Audios;
Actor->GetComponents(Audios);
bAudio = Audios.Num() > 0;
if (bPhysics) { AddW(EVerticalWarningType::ActorSimulatesPhysics, FString::Printf(TEXT("%s simulates physics; keep in persistent level."), *Actor->GetName())); }
if (bMovable) { AddW(EVerticalWarningType::ActorDynamicMobility, FString::Printf(TEXT("%s has Movable mobility."), *Actor->GetName())); }
if (bLight || bAudio) { AddW(EVerticalWarningType::ActorHasAudioLightNav, FString::Printf(TEXT("%s has %s%s components."), *Actor->GetName(), bLight ? TEXT("light ") : TEXT(""), bAudio ? TEXT("audio") : TEXT(""))); }
if (Actor->GetIsReplicated())
{
AddW(EVerticalWarningType::ActorNotStreamSafe, FString::Printf(TEXT("%s is replicated; streaming it per-client may desync."), *Actor->GetName()));
}
if (Cast<UBlueprintGeneratedClass>(Actor->GetClass()) && Actor->PrimaryActorTick.bCanEverTick)
{
AddW(EVerticalWarningType::ActorHasBlueprintTick, FString::Printf(TEXT("%s is a ticking Blueprint actor."), *Actor->GetName()));
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
bool UVerticalPartitionBuilder::Build(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport)
{
OutReport.Reset();
if (!Volume || !Volume->GetWorld())
{
return false;
}
UWorld* World = Volume->GetWorld();
const FVerticalPartitionSettings& S = Volume->Settings;
const FBox Bounds = Volume->GetVolumeBounds();
if (S.bCreateBackup)
{
FString BackupPath;
CreateBackup(World, BackupPath);
}
TArray<FVerticalActorAssignment> Assignments;
GatherActors(Volume, Bounds, Assignments, OutReport);
for (const FVerticalActorAssignment& A : Assignments)
{
if (!A.bExcluded) { ClassifyActorWarnings(A, S, OutReport); }
}
UVerticalPartitionDescriptor* Desc = CreateOrLoadDescriptor(Volume);
if (!Desc)
{
FVerticalPartitionError E;
E.Type = EVerticalWarningType::PackageSaveFailed;
E.Message = TEXT("Failed to create descriptor asset.");
OutReport.Errors.Add(E);
OutReport.ErrorCount = OutReport.Errors.Num();
return false;
}
Desc->Settings = S;
Desc->VolumeBounds = Bounds;
Desc->BuiltMode = S.ConversionMode;
Desc->SourceLevel = FSoftObjectPath(World->GetOutermost()->GetName());
BuildCells(Volume, Bounds, Assignments, Desc, OutReport);
Desc->SourceContentHash = ComputeContentHash(Assignments);
OutReport.WarningCount = OutReport.Warnings.Num();
OutReport.ErrorCount = OutReport.Errors.Num();
Desc->LastReport = OutReport;
SaveAsset(Desc);
// Link descriptor to volume + ensure a manager exists.
Volume->Descriptor = Desc;
Volume->MarkPackageDirty();
bool bHasManager = false;
for (TActorIterator<AVerticalPartitionManager> It(World); It; ++It) { bHasManager = true; break; }
if (!bHasManager)
{
if (AVerticalPartitionManager* Mgr = World->SpawnActor<AVerticalPartitionManager>())
{
Mgr->Volume = Volume;
Mgr->Descriptor = Desc;
Mgr->MarkPackageDirty();
}
}
if (S.ConversionMode == EVerticalConversionMode::DestructiveCommit)
{
CommitConversion(Volume, OutReport);
}
UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Build complete: %d cells, %d warnings, %d errors."),
OutReport.GeneratedCells, OutReport.WarningCount, OutReport.ErrorCount);
return true;
}
void UVerticalPartitionBuilder::BuildCells(AVerticalPartitionVolume* Volume, const FBox& VolumeBounds,
const TArray<FVerticalActorAssignment>& Assignments, UVerticalPartitionDescriptor* Descriptor, FVerticalPartitionBuildReport& Report)
{
const FVerticalPartitionSettings& S = Volume->Settings;
const FString PackageRoot = S.GeneratedPackageRoot;
// Group assignments by cell (skip excluded / persistent-kept).
TMap<FVerticalCellId, TArray<AActor*>> CellActors;
for (const FVerticalActorAssignment& A : Assignments)
{
if (A.bExcluded || A.bLargeKeepPersistent)
{
continue;
}
TArray<AActor*>& Bucket = CellActors.FindOrAdd(A.Cell);
for (const TWeakObjectPtr<AActor>& Leaf : A.LeafActors)
{
if (AActor* L = Leaf.Get())
{
Bucket.Add(L);
}
}
}
Descriptor->Cells.Reset();
Descriptor->Cells.Reserve(CellActors.Num());
for (TPair<FVerticalCellId, TArray<AActor*>>& Pair : CellActors)
{
FVerticalCellDescriptor CD;
CD.CellId = Pair.Key;
CD.Bounds = UVerticalPartitionStatics::GetCellBounds(Pair.Key, VolumeBounds, S);
for (AActor* A : Pair.Value)
{
if (!A) { continue; }
CD.SourceActors.Add(FSoftObjectPath(A));
++CD.ActorCount;
TArray<UStaticMeshComponent*> SMComps;
A->GetComponents(SMComps);
if (SMComps.Num() > 0) { ++CD.StaticMeshCount; CD.EstimatedMemoryMB += 0.25f * SMComps.Num(); }
if (Cast<UBlueprintGeneratedClass>(A->GetClass())) { ++CD.BlueprintActorCount; }
TArray<ULightComponentBase*> Lights;
A->GetComponents(Lights);
if (Lights.Num() > 0) { ++CD.LightCount; }
if (USceneComponent* Root = A->GetRootComponent())
{
if (Root->Mobility == EComponentMobility::Movable) { ++CD.DynamicActorCount; }
}
CD.EstimatedMemoryMB += 0.1f;
}
if (S.bGenerateHLODProxies)
{
CD.HLODProxyAsset = GenerateHLODProxyForCell(Pair.Key, Pair.Value, S, PackageRoot, Report);
}
// NOTE: FullCellPackage is left empty here — NonDestructive uses the visibility
// backend. DestructiveCommit fills it in CommitConversion().
CD.bHasWarnings = false; // per-cell warning aggregation can be refined later
Descriptor->Cells.Add(CD);
}
Report.GeneratedCells = Descriptor->Cells.Num();
// Empty Z cells (no actors) for reporting completeness.
const int32 NumZ = UVerticalPartitionStatics::GetNumZCells(VolumeBounds, S);
int32 NonEmptyZ = 0;
{
TSet<int32> SeenZ;
for (const FVerticalCellDescriptor& C : Descriptor->Cells) { SeenZ.Add(C.CellId.Z); }
NonEmptyZ = SeenZ.Num();
}
Report.EmptyCells = FMath::Max(0, NumZ - NonEmptyZ);
}
FSoftObjectPath UVerticalPartitionBuilder::GenerateHLODProxyForCell(const FVerticalCellId& Cell,
const TArray<AActor*>& CellActors, const FVerticalPartitionSettings& S, const FString& PackageRoot,
FVerticalPartitionBuildReport& Report)
{
// Collect STATIC mesh components only — incl. ISM/HISM from Packed Level Actors and from
// flattened Level Instance children. Merge auto-expands instances into one object.
TArray<UStaticMeshComponent*> SMComps;
UWorld* World = nullptr;
for (AActor* A : CellActors)
{
if (!A) { continue; }
if (!World) { World = A->GetWorld(); }
TArray<UStaticMeshComponent*> Comps;
A->GetComponents(Comps);
for (UStaticMeshComponent* C : Comps)
{
if (C && C->GetStaticMesh() && C->Mobility == EComponentMobility::Static)
{
SMComps.Add(C);
}
}
if (SMComps.Num() >= S.HLODMaxSourceActorsPerCell) { break; }
}
if (SMComps.Num() == 0 || !World)
{
return FSoftObjectPath(); // nothing static to merge; cell just hides when far
}
const FString MapName = FPackageName::GetShortName(World->GetOutermost()->GetName());
const FString AssetName = FString::Printf(TEXT("HLOD_%s"), *Cell.ToLabel(S.bUseXYSubCells));
const FString PackagePath = PackageRoot / MapName / TEXT("Proxies") / AssetName;
UPackage* Package = CreatePackage(*PackagePath);
if (!Package)
{
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::HLODGenerationFailed;
W.Cell = Cell;
W.Message = FString::Printf(TEXT("Could not create proxy package %s."), *PackagePath);
Report.Warnings.Add(W);
return FSoftObjectPath();
}
const IMeshMergeUtilities& Merge = FModuleManager::Get().LoadModuleChecked<IMeshMergeModule>("MeshMergeUtilities").GetUtilities();
UStaticMesh* Proxy = nullptr;
// ---- SimplifiedProxy: ProxyLOD (simplify + single atlas material). Synchronous when
// bAllowAsync=false; needs the ProxyLODPlugin, else it no-ops and we fall back. ----
if (S.HLODMethod == EVerticalHLODMethod::SimplifiedProxy)
{
FMeshProxySettings ProxySettings;
ProxySettings.ScreenSize = FMath::Clamp(S.ProxyScreenSize, 1, 1200);
TArray<UObject*> Created;
FCreateProxyDelegate Delegate;
Delegate.BindLambda([&Created](const FGuid, TArray<UObject*>& Assets) { Created.Append(Assets); });
Merge.CreateProxyMesh(SMComps, ProxySettings, Package, AssetName, FGuid::NewGuid(), Delegate, /*bAllowAsync*/false, 1.0f);
for (UObject* O : Created)
{
if (UStaticMesh* M = Cast<UStaticMesh>(O)) { Proxy = M; break; }
}
if (!Proxy)
{
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::HLODGenerationFailed;
W.Cell = Cell;
W.Message = FString::Printf(TEXT("SimplifiedProxy produced no mesh for cell %s (enable the ProxyLODPlugin). Falling back to MergedMesh."), *Cell.ToString());
Report.Warnings.Add(W);
}
}
// ---- MergedMesh (default, and SimplifiedProxy fallback) ----
if (!Proxy)
{
TArray<UPrimitiveComponent*> Prims;
Prims.Reserve(SMComps.Num());
for (UStaticMeshComponent* C : SMComps) { Prims.Add(C); }
FMeshMergingSettings MergeSettings;
MergeSettings.bMergePhysicsData = false;
// Pivot at world origin so the runtime / editor preview spawns it at (0,0,0) over the
// source. Merge auto-expands ISM/HISM instances into the single merged mesh.
MergeSettings.bPivotPointAtZero = true;
TArray<UObject*> OutAssets;
FVector OutMergedLocation = FVector::ZeroVector;
Merge.MergeComponentsToStaticMesh(Prims, World, MergeSettings, nullptr, Package, AssetName,
OutAssets, OutMergedLocation, TNumericLimits<float>::Max(), /*bSilent*/true);
for (UObject* O : OutAssets)
{
if (UStaticMesh* M = Cast<UStaticMesh>(O)) { Proxy = M; break; }
}
}
if (!Proxy)
{
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::HLODGenerationFailed;
W.Cell = Cell;
W.Message = FString::Printf(TEXT("HLOD generation produced no mesh for cell %s."), *Cell.ToString());
Report.Warnings.Add(W);
return FSoftObjectPath();
}
SaveAsset(Proxy);
return FSoftObjectPath(Proxy);
}
// ---------------------------------------------------------------------------
// Rebuild / validate
// ---------------------------------------------------------------------------
bool UVerticalPartitionBuilder::RebuildChangedCells(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport)
{
// MVP: incremental rebuild == full rebuild (the dependency graph that would let
// us touch only changed cells is a V2 item). Logged so the user knows.
UE_LOG(LogVerticalPartition, Log, TEXT("[VP] RebuildChangedCells: performing full rebuild (incremental diff is a V2 feature)."));
return Build(Volume, OutReport);
}
bool UVerticalPartitionBuilder::RebuildHLOD(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport)
{
OutReport.Reset();
if (!Volume) { return false; }
UVerticalPartitionDescriptor* Desc = Volume->Descriptor.LoadSynchronous();
if (!Desc)
{
UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] RebuildHLOD: no descriptor. Run Build first."));
return false;
}
const FVerticalPartitionSettings& S = Volume->Settings;
const FString PackageRoot = S.GeneratedPackageRoot;
for (FVerticalCellDescriptor& CD : Desc->Cells)
{
TArray<AActor*> CellActors;
for (const FSoftObjectPath& Path : CD.SourceActors)
{
if (AActor* A = Cast<AActor>(Path.ResolveObject()))
{
CellActors.Add(A);
}
}
CD.HLODProxyAsset = GenerateHLODProxyForCell(CD.CellId, CellActors, S, PackageRoot, OutReport);
}
OutReport.WarningCount = OutReport.Warnings.Num();
Desc->LastReport = OutReport;
SaveAsset(Desc);
UE_LOG(LogVerticalPartition, Log, TEXT("[VP] RebuildHLOD complete for %d cells."), Desc->Cells.Num());
return true;
}
FVerticalPartitionBuildReport UVerticalPartitionBuilder::ValidateCells(AVerticalPartitionVolume* Volume)
{
FVerticalPartitionBuildReport Report = AnalyzeCurrentLevel(Volume);
if (!Volume) { return Report; }
if (UVerticalPartitionDescriptor* Desc = Volume->Descriptor.LoadSynchronous())
{
const FBox Bounds = Volume->GetVolumeBounds();
TArray<FVerticalActorAssignment> Assignments;
FVerticalPartitionBuildReport Tmp;
GatherActors(Volume, Bounds, Assignments, Tmp);
const int64 CurrentHash = ComputeContentHash(Assignments);
if (CurrentHash != Desc->SourceContentHash)
{
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::DescriptorOutOfDate;
W.Message = TEXT("Level content changed since the last Build. Rebuild recommended.");
Report.Warnings.Add(W);
Report.WarningCount = Report.Warnings.Num();
}
}
return Report;
}
bool UVerticalPartitionBuilder::CommitConversion(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport)
{
// ---- V2 / DestructiveCommit ----
// Intended algorithm (kept as a stub so the plugin compiles & the safe
// NonDestructive path ships first, exactly as recommended in the README MVP):
// 1. For each cell with actors, UEditorLevelUtils::CreateNewStreamingLevel()
// a new sublevel package under <Root>/<Map>/Cells/Cell_Zxxxx.umap.
// 2. UEditorLevelUtils::MoveActorsToLevel(CellActors, NewLevel) to bake them out.
// 3. Set FVerticalCellDescriptor::FullCellPackage to the new map's soft path so
// the runtime switches that cell to the streaming-level backend.
// 4. SavePackage() the sublevels + the (now light) persistent level.
// This mutates the level destructively, so it must run only after CreateBackup().
UE_LOG(LogVerticalPartition, Warning,
TEXT("[VP] CommitConversion (DestructiveCommit) is a V2 feature and is not yet baking sublevels. ")
TEXT("The descriptor still works via the NonDestructive visibility backend. See README section 'Destructive Commit'."));
FVerticalPartitionWarning W;
W.Type = EVerticalWarningType::DescriptorOutOfDate;
W.Message = TEXT("DestructiveCommit not yet implemented; running as NonDestructive.");
OutReport.Warnings.Add(W);
OutReport.WarningCount = OutReport.Warnings.Num();
return false;
}
// ---------------------------------------------------------------------------
// Backup / clear / report
// ---------------------------------------------------------------------------
bool UVerticalPartitionBuilder::CreateBackup(UWorld* World, FString& OutBackupPath)
{
if (!World)
{
return false;
}
const FString PackageName = World->GetOutermost()->GetName();
FString SrcFile;
if (!FPackageName::TryConvertLongPackageNameToFilename(PackageName, SrcFile, FPackageName::GetMapPackageExtension())
|| !IFileManager::Get().FileExists(*SrcFile))
{
UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] CreateBackup: source map not found on disk (save the map first)."));
return false;
}
const FString MapShort = FPackageName::GetShortName(PackageName);
const FString Stamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S"));
OutBackupPath = FPaths::ProjectSavedDir() / TEXT("VerticalPartition") / TEXT("Backups") / FString::Printf(TEXT("%s_%s.umap"), *MapShort, *Stamp);
IFileManager::Get().MakeDirectory(*FPaths::GetPath(OutBackupPath), true);
const bool bOk = (IFileManager::Get().Copy(*OutBackupPath, *SrcFile) == COPY_OK);
UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Backup %s -> %s"), bOk ? TEXT("created") : TEXT("FAILED"), *OutBackupPath);
return bOk;
}
bool UVerticalPartitionBuilder::RestoreFromBackup(UWorld* World)
{
if (!World)
{
return false;
}
const FString PackageName = World->GetOutermost()->GetName();
const FString MapShort = FPackageName::GetShortName(PackageName);
const FString BackupDir = FPaths::ProjectSavedDir() / TEXT("VerticalPartition") / TEXT("Backups");
TArray<FString> Found;
IFileManager::Get().FindFiles(Found, *(BackupDir / (MapShort + TEXT("_*.umap"))), true, false);
if (Found.Num() == 0)
{
UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] RestoreFromBackup: no backups for %s in %s."), *MapShort, *BackupDir);
return false;
}
Found.Sort();
const FString Latest = BackupDir / Found.Last();
FString DstFile;
FPackageName::TryConvertLongPackageNameToFilename(PackageName, DstFile, FPackageName::GetMapPackageExtension());
const bool bOk = (IFileManager::Get().Copy(*DstFile, *Latest) == COPY_OK);
UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] Restored %s from %s (%s). Reload the map to see changes."),
*MapShort, *Latest, bOk ? TEXT("OK") : TEXT("FAILED"));
return bOk;
}
void UVerticalPartitionBuilder::ClearGenerated(AVerticalPartitionVolume* Volume)
{
if (!Volume)
{
return;
}
const FVerticalPartitionSettings& S = Volume->Settings;
UWorld* World = Volume->GetWorld();
const FString MapName = World ? FPackageName::GetShortName(World->GetOutermost()->GetName()) : TEXT("");
const FString GeneratedPackageDir = S.GeneratedPackageRoot / MapName;
FString DiskDir;
if (FPackageName::TryConvertLongPackageNameToFilename(GeneratedPackageDir, DiskDir))
{
if (IFileManager::Get().DirectoryExists(*DiskDir))
{
IFileManager::Get().DeleteDirectory(*DiskDir, false, true);
UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Cleared generated data at %s"), *DiskDir);
}
}
Volume->Descriptor.Reset();
Volume->MarkPackageDirty();
}
FString UVerticalPartitionBuilder::DumpReport(const FVerticalPartitionBuildReport& Report, const FString& MapName)
{
FString Out;
Out += FString::Printf(TEXT("Vertical Partition Build Report - %s\n"), *MapName);
Out += TEXT("==========================================\n");
Out += FString::Printf(TEXT("Total actors scanned : %d\n"), Report.TotalActorsScanned);
Out += FString::Printf(TEXT("Actors inside volume : %d\n"), Report.ActorsInsideVolume);
Out += FString::Printf(TEXT("Actors excluded : %d\n"), Report.ActorsExcluded);
Out += FString::Printf(TEXT("Generated cells : %d\n"), Report.GeneratedCells);
Out += FString::Printf(TEXT("Empty cells : %d\n"), Report.EmptyCells);
Out += FString::Printf(TEXT("Largest cell : %s (%d actors)\n"), *Report.LargestCell.ToString(), Report.LargestCellActorCount);
Out += FString::Printf(TEXT("Warnings / Errors : %d / %d\n"), Report.WarningCount, Report.ErrorCount);
Out += TEXT("\n-- Warnings --\n");
for (const FVerticalPartitionWarning& W : Report.Warnings)
{
Out += FString::Printf(TEXT(" [%s] %s\n"), *UVerticalPartitionStatics::WarningTypeToString(W.Type), *W.Message);
}
Out += TEXT("\n-- Errors --\n");
for (const FVerticalPartitionError& E : Report.Errors)
{
Out += FString::Printf(TEXT(" [%s] %s\n"), *UVerticalPartitionStatics::WarningTypeToString(E.Type), *E.Message);
}
const FString Path = FPaths::ProjectSavedDir() / TEXT("VerticalPartition") / FString::Printf(TEXT("%s_Report.txt"), *MapName);
FFileHelper::SaveStringToFile(Out, *Path);
UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Report written to %s\n%s"), *Path, *Out);
return Path;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
UVerticalPartitionDescriptor* UVerticalPartitionBuilder::CreateOrLoadDescriptor(AVerticalPartitionVolume* Volume)
{
UWorld* World = Volume->GetWorld();
if (!World)
{
return nullptr;
}
const FString MapName = FPackageName::GetShortName(World->GetOutermost()->GetName());
const FString AssetName = FString::Printf(TEXT("VPD_%s"), *MapName);
const FString PackagePath = Volume->Settings.GeneratedPackageRoot / MapName / AssetName;
// Re-use the existing descriptor if it is already linked / on disk.
if (UVerticalPartitionDescriptor* Existing = Volume->Descriptor.LoadSynchronous())
{
return Existing;
}
if (UVerticalPartitionDescriptor* OnDisk = LoadObject<UVerticalPartitionDescriptor>(nullptr, *(PackagePath + TEXT(".") + AssetName)))
{
return OnDisk;
}
UPackage* Package = CreatePackage(*PackagePath);
if (!Package)
{
return nullptr;
}
UVerticalPartitionDescriptor* Desc = NewObject<UVerticalPartitionDescriptor>(Package, *AssetName, RF_Public | RF_Standalone);
if (Desc)
{
FAssetRegistryModule::AssetCreated(Desc);
}
return Desc;
}
bool UVerticalPartitionBuilder::SaveAsset(UObject* Asset)
{
if (!Asset)
{
return false;
}
UPackage* Package = Asset->GetOutermost();
Package->MarkPackageDirty();
const FString FileName = FPackageName::LongPackageNameToFilename(Package->GetName(), FPackageName::GetAssetPackageExtension());
FSavePackageArgs Args;
Args.TopLevelFlags = RF_Public | RF_Standalone;
Args.SaveFlags = SAVE_NoError;
Args.Error = GError;
const bool bSaved = UPackage::SavePackage(Package, Asset, *FileName, Args);
if (!bSaved)
{
UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] Failed to save %s"), *Package->GetName());
}
return bSaved;
}
int64 UVerticalPartitionBuilder::ComputeContentHash(const TArray<FVerticalActorAssignment>& Assignments)
{
uint32 Hash = 0;
for (const FVerticalActorAssignment& A : Assignments)
{
if (AActor* Actor = A.Actor.Get())
{
Hash = HashCombine(Hash, GetTypeHash(Actor->GetFName()));
const FVector P = Actor->GetActorLocation();
Hash = HashCombine(Hash, GetTypeHash(FIntVector(FMath::RoundToInt(P.X), FMath::RoundToInt(P.Y), FMath::RoundToInt(P.Z))));
}
}
Hash = HashCombine(Hash, (uint32)Assignments.Num());
return (int64)Hash;
}