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>
1006 lines
33 KiB
C++
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;
|
|
}
|