From 04ad6d3f257a1abeaf71493e1c4b39f049413451 Mon Sep 17 00:00:00 2001 From: Bonchellon <64547270+bonchellon@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:42:41 +0300 Subject: [PATCH] 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 --- .gitignore | 11 + README.md | 274 +++++ .../VerticalPartitionDebugComponent.cpp | 114 ++ .../Private/VerticalPartitionDescriptor.cpp | 32 + .../Private/VerticalPartitionManager.cpp | 189 ++++ .../Private/VerticalPartitionModule.cpp | 179 +++ .../VerticalPartitionRuntimeSubsystem.cpp | 632 +++++++++++ .../Private/VerticalPartitionSettings.cpp | 12 + .../Private/VerticalPartitionStatics.cpp | 257 +++++ .../Private/VerticalPartitionVolume.cpp | 71 ++ .../Public/VerticalPartitionDebugComponent.h | 31 + .../Public/VerticalPartitionDescriptor.h | 50 + .../Public/VerticalPartitionLog.h | 6 + .../Public/VerticalPartitionManager.h | 62 + .../VerticalPartitionRuntimeSubsystem.h | 119 ++ .../Public/VerticalPartitionSettings.h | 36 + .../Public/VerticalPartitionStatics.h | 71 ++ .../Public/VerticalPartitionTypes.h | 486 ++++++++ .../Public/VerticalPartitionVolume.h | 89 ++ .../VerticalPartition.Build.cs | 30 + .../Private/VerticalPartitionBuilder.cpp | 1005 +++++++++++++++++ .../Private/VerticalPartitionCommandlet.cpp | 62 + .../Private/VerticalPartitionEditorModule.cpp | 264 +++++ .../Public/VerticalPartitionBuilder.h | 89 ++ .../Public/VerticalPartitionCommandlet.h | 20 + .../VerticalPartitionEditor.Build.cs | 37 + USAGE_RU.md | 109 ++ VerticalPartition.uplugin | 24 + 28 files changed, 4361 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Source/VerticalPartition/Private/VerticalPartitionDebugComponent.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionDescriptor.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionManager.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionModule.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionRuntimeSubsystem.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionSettings.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionStatics.cpp create mode 100644 Source/VerticalPartition/Private/VerticalPartitionVolume.cpp create mode 100644 Source/VerticalPartition/Public/VerticalPartitionDebugComponent.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionDescriptor.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionLog.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionManager.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionRuntimeSubsystem.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionSettings.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionStatics.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionTypes.h create mode 100644 Source/VerticalPartition/Public/VerticalPartitionVolume.h create mode 100644 Source/VerticalPartition/VerticalPartition.Build.cs create mode 100644 Source/VerticalPartitionEditor/Private/VerticalPartitionBuilder.cpp create mode 100644 Source/VerticalPartitionEditor/Private/VerticalPartitionCommandlet.cpp create mode 100644 Source/VerticalPartitionEditor/Private/VerticalPartitionEditorModule.cpp create mode 100644 Source/VerticalPartitionEditor/Public/VerticalPartitionBuilder.h create mode 100644 Source/VerticalPartitionEditor/Public/VerticalPartitionCommandlet.h create mode 100644 Source/VerticalPartitionEditor/VerticalPartitionEditor.Build.cs create mode 100644 USAGE_RU.md create mode 100644 VerticalPartition.uplugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a27c8a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Unreal build artifacts +Binaries/ +Intermediate/ +Saved/ +DerivedDataCache/ +*.pdb + +# OS / editor cruft +.DS_Store +Thumbs.db +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..7aa656c --- /dev/null +++ b/README.md @@ -0,0 +1,274 @@ +# Vertical Partition Streaming + +> **This is not an infinite open-world partition system.** +> **This is a bounded vertical route partition system for a hand-crafted spiral climbing map** (Only Up / Only Climb style). + +It takes an *already finished* level, scans the actors inside a volume, segments them +into vertical **Z cells** (optionally **XY sub-cells**), bakes a **descriptor**, generates +**offline HLOD proxies**, and streams the route at runtime with a vertical-aware policy +(full / preload / HLOD / unloaded bands, hysteresis, velocity prediction, fall-risk-below), +plus World-Partition-style debug visualization. + +It is **not** World Partition, it is **not** Level Instances, and it does **no** runtime +mesh merging. Full cells use classic persistent-level + streaming-sublevel loading (or, in +the default safe mode, visibility toggling of the existing actors). + +--- + +## 1. How it understands the task + +| Requirement | How it's met | +|---|---| +| Bounded vertical route, spiral, handcrafted | `AVerticalPartitionVolume` (a box) defines the playable region; cells are slices of Z. | +| Convert an existing map | `UVerticalPartitionBuilder` scans the level in place — nothing is authored from scratch. | +| World-Partition *by meaning*, not infinite XY | Z is the primary axis; XY sub-cells are optional refinement. | +| Full / HLOD / preload / unload ranges | `FVerticalPartitionSettings` + `UVerticalPartitionStatics::GetDesiredStateForCell`. | +| Offline HLOD, no runtime merge | `MeshMergeUtilities` at build time → per-cell proxy `UStaticMesh`. | +| Two conversion modes | `NonDestructive` (ships first, safe) and `DestructiveCommit` (V2). | +| WP-style debug | `UVerticalPartitionDebugComponent` (3D boxes + overlay) + `vp.*` console. | + +--- + +## 2. Architecture + +``` +EDITOR (VerticalPartitionEditor module) RUNTIME (VerticalPartition module) +┌───────────────────────────┐ ┌────────────────────────────────┐ +│ AVerticalPartitionVolume │ CallInEditor btns │ AVerticalPartitionManager │ +│ • box bounds + settings │──┐ via bridge │ • finds tracked pawn │ +│ • Descriptor ref │ │ │ • builds streaming context │ +└───────────────────────────┘ │ │ • drives subsystem @ interval │ + ▲ ▼ │ • hosts DebugComponent │ + │ FVerticalPartitionEditorBridge └───────────────┬────────────────┘ + │ │ │ UpdateStreaming() +┌───────────┴────────────┐ ▼ ┌────────────────▼────────────────┐ +│ UVerticalPartitionBuilder│ Analyze/Build/... │ UVerticalPartitionRuntimeSubsystem│ +│ • GatherActors │────────────────────────▶│ • per-cell state machine │ +│ • BuildCells │ writes │ • streaming-level OR visibility │ +│ • GenerateHLODProxy │ ┌──────────────────┐ │ • proxy actors, async budget │ +│ • CreateBackup/Commit │────▶│ UVerticalPartition │◀│ • FVerticalRuntimeStats │ +└──────────────────────────┘ │ Descriptor (asset) │ └───────────────────────────────────┘ + │ • cells + bounds │ +UVerticalPartitionCommandlet ────▶│ • settings snapshot│ UVerticalPartitionStatics (pure math, + (offline -run=VerticalPartition) │ • last report │ shared by editor + runtime) + └──────────────────┘ +``` + +**Module split** keeps the heavy editor APIs (mesh merge, level surgery) out of packaged +builds. The volume lives in the runtime module so it can be streamed/placed at runtime, and +its editor buttons reach the builder through `FVerticalPartitionEditorBridge` (a `TFunction` +the editor module installs on startup). + +--- + +## 3. Editor conversion pipeline + +1. Open the finished map. +2. Drop an **`AVerticalPartitionVolume`** around the whole playable spiral; scale its box. +3. Tune **Settings** on the volume (segmentation, ranges, HLOD, large-actor policy, exclude classes, mode). +4. Press the detail-panel buttons (or `vp.*`): + - **Analyze** → report only (no writes): counts, cells, largest cell, warnings. + - **Build** → backup → scan → classify → `VPD_` descriptor → HLOD proxies → save → spawn a manager. + - **Validate / Preview Chunks / Rebuild HLOD / Rebuild Changed / Clear Generated / Commit / Restore Backup / Dump Report**. +5. Press **Play** — the manager registers the descriptor with the subsystem and streaming starts. +6. Toggle debug (`vp.Debug 1`). + +**Generated content** lives under `Settings.GeneratedPackageRoot` (default `/Game/VPGenerated//`): +`VPD_` (descriptor) and `Proxies/HLOD_Cell_Zxxxx` (proxy meshes). Backups go to +`Saved/VerticalPartition/Backups/`. + +### Conversion modes +- **NonDestructive (default, ships first):** the persistent level is untouched. The descriptor + records each cell's source actors by soft path; at runtime far cells are **hidden** + (`SetActorHiddenInGame` + collision/tick off) and their HLOD proxy shown. Zero risk, fully + reversible, validates the whole pipeline. *No memory savings* (actors stay resident). +- **DestructiveCommit (V2):** actors are baked into per-cell streaming sub-level `.umap` + packages and removed from the persistent level → true memory streaming. A backup is taken + first. The exact baking steps are documented in `CommitConversion()` and below; the runtime + already supports this backend (a cell with a valid `FullCellPackage` switches to streaming). + +> **Recommended MVP: NonDestructive.** It's safe, gives draw-call savings + correct streaming +> behavior, and lets you tune ranges before committing to destructive level surgery. + +--- + +## 4. Runtime streaming pipeline + +`AVerticalPartitionManager::Tick` (throttled to `UpdateInterval`) → +builds `FVerticalStreamingContext` (player Z cell, velocity Z, camera pitch, teleport flag) → +`UVerticalPartitionRuntimeSubsystem::UpdateStreaming`: + +1. **PollStreaming** each cell (refresh state from in-flight async ops). +2. **Desired state** per cell = `GetDesiredStateForCell` (or forced / show-only). +3. **Budget pass** — nearest `MaxFullCells` stay Full, nearest `MaxVisibleHLODCells` stay HLOD, the rest demote. +4. **Apply**, nearest-first, within `MaxAsyncLoadsPerFrame` / `MaxAsyncUnloadsPerFrame`: + - `LoadCellFullAsync(visible)` — streaming backend: `SetShouldBeLoaded/Visible`. + - `ShowCellHLOD` — spawn/show the proxy `AStaticMeshActor`. + - `UnloadCell` — hide actors / unload streaming level / hide proxy. +5. **Rebuild stats**, record update time, flag hitches. + +`bSnap` (teleport / checkpoint / first frame) bypasses the per-frame budget for instant convergence. + +--- + +## 5. Streaming ranges (all editable per-volume) + +Bands are measured in **cells from the player cell**. Example with `Full=±1`, `Preload above=3`, +`HLOD=±... ` and player in **Z10**: + +``` +Z15 ── Unloaded ─────────────── +Z14 ─┐ +Z13 ─┤ HLOD (proxy visible) +Z12 ─┘ (+ Preload: full data resident but hidden) +Z11 ─┐ +Z10 ─┤ FULL ← player +Z9 ─┘ +Z8 ─┐ HLOD +Z7 ─┘ +Z6 ── Unloaded ─────────────── +``` + +Key knobs (see `FVerticalPartitionSettings`): +`FullLoadCells{Below,Above}`, `PreloadCells{Below,Above}`, `HLODLoadCells{Below,Above}`, +`UnloadCells{Below,Above}`, `HysteresisDistanceZ`, `HysteresisCells`, `UpdateInterval`, +`MaxAsyncLoads/UnloadsPerFrame`, `MaxVisibleHLODCells`, `MaxFullCells`, +`bPrioritizeAbovePlayer`, `bKeepBelowPlayerLoadedOnFallRisk`, `bUseCameraDirectionPriority`, +`bUsePlayerVelocityPrediction`, `VelocityPredictionSeconds`. + +`GetDesiredStateForCell` accounts for: bands, **sticky hysteresis** (a loaded cell at the edge +stays loaded for `HysteresisCells` extra), **velocity prediction** (shifts the effective player +cell), **camera direction**, **fall-risk-below** (keeps below-player cells loaded to the unload +edge), forced cells and show-only mode. Teleport is handled by the manager raising `bSnap`. + +--- + +## 6. Data structures (`VerticalPartitionTypes.h`) + +`FVerticalCellId{X,Y,Z}` · `EVerticalCellState{Unloaded,LoadingHLOD,HLOD,PreloadingFull,LoadingFull,Full,Unloading,Error}` · +`EVerticalCellDesiredState{Unloaded,HLOD,Full}` · `EVerticalConversionMode{NonDestructive,DestructiveCommit}` · +`ELargeActorPolicy{KeepInPersistentLevel,AssignToDominantCell,DuplicateAsHLODOnly,SplitNotSupportedWarning,ExcludeFromPartition}` · +`FVerticalPartitionSettings` (segmentation + streaming + conversion + HLOD + debug) · +`FVerticalCellDescriptor` · `FVerticalPartitionWarning` / `FVerticalPartitionError` · +`FVerticalPartitionBuildReport` · `FVerticalRuntimeStats`. + +--- + +## 7. Classes + +| Class | Module | Role | +|---|---|---| +| `AVerticalPartitionVolume` | Runtime | Bounds + settings + descriptor ref + editor buttons. | +| `UVerticalPartitionDeveloperSettings` | Runtime | Project-default settings + global debug (Project Settings → Plugins). | +| `UVerticalPartitionStatics` | Runtime | Pure cell math + the desired-state policy. | +| `UVerticalPartitionDescriptor` | Runtime | The build artifact (cells, bounds, settings snapshot, report). | +| `UVerticalPartitionRuntimeSubsystem` | Runtime | The streaming state machine + stats. | +| `AVerticalPartitionManager` | Runtime | Per-level coordinator; drives the subsystem; hosts debug. | +| `UVerticalPartitionDebugComponent` | Runtime | 3D cell boxes + on-screen overlay. | +| `UVerticalPartitionBuilder` | Editor | Analyze / Build / RebuildHLOD / Validate / Commit / Backup. | +| `UVerticalPartitionCommandlet` | Editor | `-run=VerticalPartition -Map=...` offline build. | + +--- + +## 8. HLOD / proxy strategy + +- **Offline only.** Built during Build / Rebuild HLOD via `IMeshMergeUtilities::MergeComponentsToStaticMesh` + over each cell's **static** mesh components → one proxy `UStaticMesh` per cell, saved under `Proxies/`. +- The runtime spawns one hidden `AStaticMeshActor` per cell from that mesh and toggles its visibility + for the HLOD band; Full load hides the proxy, so there's no double-draw. +- **Exclusions (never proxied):** dynamic / movable components, physics actors, gameplay actors, + moving platforms, lights, audio. Proxy collision is off. +- **Graceful degrade:** if a cell has no static geometry or merge fails, the cell still streams — + the far representation just hides instead of showing a proxy, and a warning is recorded. + +**Method (`Settings.HLODMethod`):** +- **MergedMesh** (default) — `MergeComponentsToStaticMesh`, one merged mesh with original + materials; ISM/HISM instances are auto-flattened into the single object. +- **SimplifiedProxy** — `CreateProxyMesh` (proxy LOD: simplified geometry + one baked atlas + material, the cheapest "true HLOD", same tech as Epic's HLOD *Simplified Mesh*). Requires the + **ProxyLODPlugin** enabled; auto-falls-back to MergedMesh with a warning if absent. + +### Level Instances & Packed Level Actors (universal) +- **Packed Level Actor** — its baked `ISM/HISM` live as owned components, caught by + `GetComponents` and merged (instances flattened to one object). +- **Level Instance** — `CollectStreamLeafActors` recursively flattens the LI (runtime API + `ULevelInstanceSubsystem::ForEachActorInLevelInstance`, gated on `IsLoaded`) into its static + child actors; nested LIs recurse; nested PLAs stay whole. Its static geometry is baked per + cell; its static children are hidden/shown at runtime; **lights / gameplay inside the LI stay + persistent**. Scan skips LI content (`GetOwningLevelInstance`) so nothing is double-counted. + An **unloaded** LI is skipped with a warning — load it in the editor and rebuild. + Toggle with `Settings.bStreamLevelInstances` (default on). + +--- + +## 9. Debug / validation + +**Console:** `vp.Debug`, `vp.DebugDraw`, `vp.DebugOverlay` (CVars); commands +`vp.Analyze`, `vp.BuildFromSelectedVolume`, `vp.Validate`, `vp.Rebuild`, `vp.RebuildHLOD`, +`vp.ForceCellFull/HLOD `, `vp.UnforceCell `, `vp.UnforceAll`, `vp.ShowOnlyCell `, +`vp.TeleportToCell `, `vp.SetFullRange `, `vp.SetHLODRange `, +`vp.SetCellHeight `, `vp.DumpState`, `vp.DumpCells`, `vp.DumpWarnings`, `vp.ProfileStreaming`. + +**3D draw colors:** Green=Full · Blue=HLOD · Yellow=Loading/Transition · Red=Error · Gray=Unloaded · Purple=Forced/locked. +**Overlay:** player Z cell, full/HLOD/unloaded counts, loading queue, async ops, est. memory, update ms, hitches. +**Editor preview:** *Preview Chunks* draws the Z slices in the editor viewport without entering PIE. + +**Validation warnings** (analyze/build): crosses-multiple-cells, too-large, physics, +blueprint-tick, dynamic-mobility, audio/light/nav, replicated/not-stream-safe, cell-too-many-actors, +HLOD-generation-failed, package-save-failed, descriptor-out-of-date. + +--- + +## 10. Edge cases & how they're handled + +| Case | Handling | +|---|---| +| Actor crosses Z cells | Assigned to dominant (centre) cell + `ActorCrossesMultipleCells` warning. | +| Origin in one cell, bounds in another | `bUseActorBoundsInsteadOfOrigin` picks the rule; assignment uses bounds centre. | +| Attached actors split across cells | Grouped per cell in the descriptor; cross-cell attaches are a documented warning to review. | +| Blueprint tick / physics / interactable / movable | Flagged; `LargeActorPolicy`/exclude classes keep them in the persistent level. | +| SaveGame / replicated actors | `ActorNotStreamSafe` warning — keep in persistent level (NonDestructive leaves them resident). | +| Hard ref into another cell | `Dependencies` array on the descriptor (dependency graph is a V2 expansion). | +| Decor visible far / silhouette | That's the HLOD proxy's job. | +| Light in one cell lighting another | Lights flagged; recommended kept in persistent level or Full cell, not proxied. | +| Level Instance / Packed Level Actor | Universal: PLA's HISM and LI's flattened static children baked per-cell HLOD + streamed; LI lights/gameplay stay persistent. `bStreamLevelInstances`. | +| HISM / ISM (incl. inside PLA) | Merge auto-expands instances into one merged proxy object. | +| Unloaded Level Instance at build | Skipped with a warning — load it in the editor and rebuild. | +| Navmesh on a vertical map | Out of scope of streaming; keep navmesh in the persistent level. | +| Player falls 10 cells / flies up fast | Velocity prediction + `bKeepBelowPlayerLoadedOnFallRisk` + `bSnap` on big jumps. | +| Teleport / checkpoint | Manager detects the jump (>3 cells) → `bSnap` immediate convergence. | +| Multiplayer | Streaming is per-world around the local tracked pawn (client-side); replicated actors flagged. Server-authoritative union streaming is V2. | +| PIE vs packaged | Editor module is `Type:Editor` (excluded from packaged); runtime is self-contained. | +| Package load failure | Cell → `Error` state (red), logged. | +| HLOD gen failure | Warning; cell still streams (hides far). | +| Source map changed after build | `Validate` flags `DescriptorOutOfDate` via content hash. | +| Rebuild only changed cells | `RebuildChangedCells` (currently full rebuild; incremental diff is V2). | + +--- + +## 11. MVP roadmap + +**MVP (implemented):** one volume · Z-only segmentation · `CellHeight` · Analyze · Build +(NonDestructive) · descriptor asset · runtime manager + subsystem · full load/unload via +visibility · offline HLOD proxy for static-mesh actors · Z-slice debug draw · stats overlay · +warnings for large/dynamic/blueprint/physics actors · full `vp.*` console. + +**V2 (scaffolded / documented):** XY sub-cells (settings + math in place) · DestructiveCommit +baking (`CommitConversion` stub + documented steps) · incremental rebuild (dependency graph) · +full HLOD LOD chains · commandlet hardening · multiplayer union streaming · velocity/camera +preload tuning · memory profiler integration (Insights) · editor tool *window* (today: detail +buttons + console). + +--- + +## 12. Build & enable + +New C++ **modules** were added, so a Live-Coding pass is not enough — regenerate project files +and do a full rebuild with the editor closed, then enable the plugin (it's `EnabledByDefault`), +or add to `IHY.uproject`: + +```json +{ "Name": "VerticalPartition", "Enabled": true } +``` + +Files live under `Plugins/VerticalPartition/Source/{VerticalPartition,VerticalPartitionEditor}/`. diff --git a/Source/VerticalPartition/Private/VerticalPartitionDebugComponent.cpp b/Source/VerticalPartition/Private/VerticalPartitionDebugComponent.cpp new file mode 100644 index 0000000..f374b02 --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionDebugComponent.cpp @@ -0,0 +1,114 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionDebugComponent.h" +#include "VerticalPartitionRuntimeSubsystem.h" +#include "VerticalPartitionStatics.h" +#include "Engine/Engine.h" +#include "DrawDebugHelpers.h" + +static TAutoConsoleVariable CVarVPDebug( + TEXT("vp.Debug"), 0, + TEXT("Master Vertical Partition debug toggle (gates DebugDraw + DebugOverlay)."), ECVF_Default); + +static TAutoConsoleVariable CVarVPDebugDraw( + TEXT("vp.DebugDraw"), 1, + TEXT("Draw 3D coloured cell boxes / ids / counts (requires vp.Debug 1)."), ECVF_Default); + +static TAutoConsoleVariable CVarVPDebugOverlay( + TEXT("vp.DebugOverlay"), 1, + TEXT("Draw the on-screen runtime stats overlay (requires vp.Debug 1)."), ECVF_Default); + +UVerticalPartitionDebugComponent::UVerticalPartitionDebugComponent() +{ + PrimaryComponentTick.bCanEverTick = true; + PrimaryComponentTick.bStartWithTickEnabled = true; +} + +UVerticalPartitionRuntimeSubsystem* UVerticalPartitionDebugComponent::GetSub() const +{ + return UVerticalPartitionRuntimeSubsystem::Get(this); +} + +void UVerticalPartitionDebugComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + + if (CVarVPDebug.GetValueOnGameThread() == 0) + { + return; + } + UVerticalPartitionRuntimeSubsystem* Sub = GetSub(); + if (!Sub || !Sub->IsReady()) + { + return; + } + + if (CVarVPDebugDraw.GetValueOnGameThread() != 0) + { + DrawDebugCells(Sub); + } + if (CVarVPDebugOverlay.GetValueOnGameThread() != 0) + { + DrawOverlay(Sub); + } +} + +void UVerticalPartitionDebugComponent::DrawDebugCells(UVerticalPartitionRuntimeSubsystem* Sub) const +{ +#if ENABLE_DRAW_DEBUG + UWorld* World = GetWorld(); + if (!World || !Sub) + { + return; + } + + for (const TPair& Pair : Sub->GetCellRuntimes()) + { + const FVerticalCellRuntime& C = Pair.Value; + if (!C.Bounds.IsValid) + { + continue; + } + + // Purple overrides the state colour when a cell is force-locked. + FColor Color = C.bForced + ? FColor(180, 60, 220) + : UVerticalPartitionStatics::GetStateColor(C.State).ToFColor(true); + + const FVector Center = C.Bounds.GetCenter(); + const FVector Extent = C.Bounds.GetExtent(); + DrawDebugBox(World, Center, Extent, Color, false, -1.f, 0, 4.f); + + const FString Label = FString::Printf(TEXT("%s [%s] actors:%d"), + *C.Id.ToString(), + C.bForced ? TEXT("LOCK") : *UEnum::GetDisplayValueAsText(C.State).ToString(), + C.ResolvedActors.Num()); + DrawDebugString(World, Center + FVector(0, 0, Extent.Z * 0.5f), Label, nullptr, Color, 0.f, true); + } +#endif // ENABLE_DRAW_DEBUG +} + +void UVerticalPartitionDebugComponent::DrawOverlay(UVerticalPartitionRuntimeSubsystem* Sub) const +{ + if (!GEngine || !Sub) + { + return; + } + + const FVerticalRuntimeStats& S = Sub->GetStats(); + const int32 Key = 0x5650; // 'VP' + auto Line = [&](int32 Slot, const FColor& Col, const FString& Text) + { + GEngine->AddOnScreenDebugMessage(Key + Slot, 0.f, Col, Text); + }; + + Line(0, FColor::Cyan, TEXT("==== Vertical Partition ====")); + Line(1, FColor::White, FString::Printf(TEXT("Player Z cell: %d"), S.PlayerCellZ)); + Line(2, FColor::Green, FString::Printf(TEXT("Full cells: %d"), S.FullCellsLoaded)); + Line(3, FColor::Blue, FString::Printf(TEXT("HLOD cells: %d"), S.HLODCellsVisible)); + Line(4, FColor::Silver, FString::Printf(TEXT("Unloaded: %d"), S.CellsUnloaded)); + Line(5, FColor::Yellow, FString::Printf(TEXT("Loading/async: %d / %d"), S.CellsLoading, S.ActiveAsyncLoads)); + Line(6, FColor::White, FString::Printf(TEXT("Est. memory: %.1f MB"), S.EstimatedMemoryMB)); + Line(7, FColor::White, FString::Printf(TEXT("Update time: %.2f ms"), S.LastUpdateTimeMS)); + Line(8, S.HitchesOverThreshold > 0 ? FColor::Red : FColor::White, + FString::Printf(TEXT("Hitches: %d"), S.HitchesOverThreshold)); +} diff --git a/Source/VerticalPartition/Private/VerticalPartitionDescriptor.cpp b/Source/VerticalPartition/Private/VerticalPartitionDescriptor.cpp new file mode 100644 index 0000000..4e35b1e --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionDescriptor.cpp @@ -0,0 +1,32 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionDescriptor.h" + +int32 UVerticalPartitionDescriptor::FindCellIndex(const FVerticalCellId& Id) const +{ + for (int32 i = 0; i < Cells.Num(); ++i) + { + if (Cells[i].CellId == Id) + { + return i; + } + } + return INDEX_NONE; +} + +const FVerticalCellDescriptor* UVerticalPartitionDescriptor::FindCell(const FVerticalCellId& Id) const +{ + const int32 Index = FindCellIndex(Id); + return Cells.IsValidIndex(Index) ? &Cells[Index] : nullptr; +} + +bool UVerticalPartitionDescriptor::HasFullPackages() const +{ + for (const FVerticalCellDescriptor& Cell : Cells) + { + if (Cell.FullCellPackage.IsValid()) + { + return true; + } + } + return false; +} diff --git a/Source/VerticalPartition/Private/VerticalPartitionManager.cpp b/Source/VerticalPartition/Private/VerticalPartitionManager.cpp new file mode 100644 index 0000000..ccc8453 --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionManager.cpp @@ -0,0 +1,189 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionManager.h" +#include "VerticalPartitionDebugComponent.h" +#include "VerticalPartitionRuntimeSubsystem.h" +#include "VerticalPartitionDescriptor.h" +#include "VerticalPartitionVolume.h" +#include "VerticalPartitionStatics.h" +#include "VerticalPartitionLog.h" +#include "EngineUtils.h" // TActorIterator +#include "GameFramework/Pawn.h" +#include "GameFramework/PlayerController.h" +#include "Camera/PlayerCameraManager.h" +#include "Engine/World.h" + +AVerticalPartitionManager::AVerticalPartitionManager() +{ + PrimaryActorTick.bCanEverTick = true; + PrimaryActorTick.bStartWithTickEnabled = true; + + DebugComponent = CreateDefaultSubobject(TEXT("DebugComponent")); +} + +void AVerticalPartitionManager::BeginPlay() +{ + Super::BeginPlay(); + + UWorld* World = GetWorld(); + + // Resolve a volume if none was assigned. + if (!Volume && World) + { + for (TActorIterator It(World); It; ++It) + { + Volume = *It; + break; + } + } + + // Resolve the descriptor: explicit -> volume's -> none. + UVerticalPartitionDescriptor* Desc = Descriptor.LoadSynchronous(); + if (!Desc && Volume) + { + Desc = Volume->Descriptor.LoadSynchronous(); + } + + if (!Desc) + { + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] Manager found no descriptor. Build the partition first. Streaming disabled.")); + SetActorTickEnabled(false); + return; + } + + const FBox Bounds = Volume ? Volume->GetVolumeBounds() : Desc->VolumeBounds; + + if (UVerticalPartitionRuntimeSubsystem* Sub = UVerticalPartitionRuntimeSubsystem::Get(this)) + { + Sub->RegisterDescriptor(Desc, Bounds); + } + + bSnapNextUpdate = true; +} + +void AVerticalPartitionManager::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + if (UVerticalPartitionRuntimeSubsystem* Sub = UVerticalPartitionRuntimeSubsystem::Get(this)) + { + Sub->UnregisterDescriptor(); + } + Super::EndPlay(EndPlayReason); +} + +APawn* AVerticalPartitionManager::ResolveTrackedPawn() +{ + if (TrackedPawn.IsValid()) + { + return TrackedPawn.Get(); + } + if (UWorld* World = GetWorld()) + { + if (APlayerController* PC = World->GetFirstPlayerController()) + { + return PC->GetPawn(); + } + } + return nullptr; +} + +bool AVerticalPartitionManager::BuildContext(FVerticalStreamingContext& OutCtx, float DeltaSeconds, bool& bOutTeleport) +{ + bOutTeleport = false; + + APawn* Pawn = ResolveTrackedPawn(); + UVerticalPartitionRuntimeSubsystem* Sub = UVerticalPartitionRuntimeSubsystem::Get(this); + if (!Pawn || !Sub || !Sub->IsReady()) + { + return false; + } + + const FVerticalPartitionSettings& S = Sub->GetSettings(); + const FBox Bounds = Sub->GetVolumeBounds(); + const FVector Pos = Pawn->GetActorLocation(); + + // Velocity (smoothed) + teleport detection. + if (bHavePrevPos && DeltaSeconds > KINDA_SMALL_NUMBER) + { + const FVector RawVel = (Pos - PrevTrackedPos) / DeltaSeconds; + SmoothedVelocity = FMath::Lerp(SmoothedVelocity, RawVel, 0.5f); + + const float Jump = FMath::Abs(Pos.Z - PrevTrackedPos.Z); + if (Jump > S.CellHeight * 3.f) + { + bOutTeleport = true; + SmoothedVelocity = FVector::ZeroVector; + } + } + PrevTrackedPos = Pos; + bHavePrevPos = true; + + OutCtx.PlayerWorldZ = Pos.Z; + OutCtx.PlayerVelocityZ = SmoothedVelocity.Z; + OutCtx.PlayerCellZ = UVerticalPartitionStatics::GetCellZFromWorldZ(Pos.Z, Bounds.Min.Z, S.CellHeight); + OutCtx.bUseXY = S.bUseXYSubCells; + if (S.bUseXYSubCells) + { + OutCtx.PlayerCellX = UVerticalPartitionStatics::GetCellXFromWorldX(Pos.X, Bounds.Min.X, S.XYCellSize); + OutCtx.PlayerCellY = UVerticalPartitionStatics::GetCellYFromWorldY(Pos.Y, Bounds.Min.Y, S.XYCellSize); + } + + if (const APlayerController* PC = Cast(Pawn->GetController())) + { + if (PC->PlayerCameraManager) + { + OutCtx.CameraPitchDeg = PC->PlayerCameraManager->GetCameraRotation().Pitch; + } + } + + return true; +} + +void AVerticalPartitionManager::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + UVerticalPartitionRuntimeSubsystem* Sub = UVerticalPartitionRuntimeSubsystem::Get(this); + if (!Sub || !Sub->IsReady()) + { + return; + } + + UpdateAccumulator += DeltaSeconds; + const float Interval = FMath::Max(0.01f, Sub->GetSettings().UpdateInterval); + if (!bSnapNextUpdate && UpdateAccumulator < Interval) + { + return; + } + const float Elapsed = UpdateAccumulator; + UpdateAccumulator = 0.f; + + FVerticalStreamingContext Ctx; + bool bTeleport = false; + if (!BuildContext(Ctx, Elapsed, bTeleport)) + { + return; + } + + const bool bSnap = bSnapNextUpdate || bTeleport; + bSnapNextUpdate = false; + + Sub->UpdateStreaming(Ctx, Elapsed, bSnap); +} + +void AVerticalPartitionManager::TeleportToCell(int32 Z) +{ + APawn* Pawn = ResolveTrackedPawn(); + UVerticalPartitionRuntimeSubsystem* Sub = UVerticalPartitionRuntimeSubsystem::Get(this); + if (!Pawn || !Sub || !Sub->IsReady()) + { + return; + } + + const FVerticalPartitionSettings& S = Sub->GetSettings(); + const FBox Bounds = Sub->GetVolumeBounds(); + const FVector Center = Bounds.GetCenter(); + const float TargetZ = Bounds.Min.Z + (Z + 0.5f) * S.CellHeight; + + Pawn->TeleportTo(FVector(Center.X, Center.Y, TargetZ), Pawn->GetActorRotation()); + bSnapNextUpdate = true; + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Teleported tracked pawn to cell Z=%d (worldZ=%.0f)"), Z, TargetZ); +} diff --git a/Source/VerticalPartition/Private/VerticalPartitionModule.cpp b/Source/VerticalPartition/Private/VerticalPartitionModule.cpp new file mode 100644 index 0000000..c19c00e --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionModule.cpp @@ -0,0 +1,179 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// Runtime module impl + the vp.* console command surface. Build/editor commands +// route through FVerticalPartitionEditorBridge (editor builds only); runtime +// commands talk to the world's UVerticalPartitionRuntimeSubsystem / manager. +#include "Modules/ModuleManager.h" +#include "HAL/IConsoleManager.h" +#include "EngineUtils.h" +#include "Engine/World.h" +#include "VerticalPartitionLog.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionVolume.h" +#include "VerticalPartitionManager.h" +#include "VerticalPartitionRuntimeSubsystem.h" + +#if WITH_EDITOR +#include "Editor.h" +#include "Selection.h" +#endif + +DEFINE_LOG_CATEGORY(LogVerticalPartition); + +namespace VPConsole +{ + static AVerticalPartitionVolume* FindVolume(UWorld* World) + { + if (!World) { return nullptr; } +#if WITH_EDITOR + // Prefer an editor-selected volume when available. + if (GEditor) + { + if (USelection* Sel = GEditor->GetSelectedActors()) + { + for (FSelectionIterator It(*Sel); It; ++It) + { + if (AVerticalPartitionVolume* V = Cast(*It)) + { + return V; + } + } + } + } +#endif + for (TActorIterator It(World); It; ++It) + { + return *It; + } + return nullptr; + } + + static UVerticalPartitionRuntimeSubsystem* GetSub(UWorld* World) + { + return World ? World->GetSubsystem() : nullptr; + } + + static AVerticalPartitionManager* FindManager(UWorld* World) + { + if (!World) { return nullptr; } + for (TActorIterator It(World); It; ++It) + { + return *It; + } + return nullptr; + } + + static void RouteEditor(UWorld* World, EVerticalPartitionAction Action, const TCHAR* Name) + { +#if WITH_EDITOR + if (AVerticalPartitionVolume* V = FindVolume(World)) + { + FVerticalPartitionEditorBridge::Execute(V, Action); + } + else + { + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] %s: no AVerticalPartitionVolume in the level."), Name); + } +#else + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] %s is an editor-only command."), Name); +#endif + } +} + +class FVerticalPartitionModule : public IModuleInterface +{ +public: + virtual void StartupModule() override + { + using namespace VPConsole; + IConsoleManager& CM = IConsoleManager::Get(); + auto Add = [this, &CM](const TCHAR* Name, const TCHAR* Help, FConsoleCommandWithWorldAndArgsDelegate Del) + { + Commands.Add(CM.RegisterConsoleCommand(Name, Help, Del, ECVF_Default)); + }; + + // ---- runtime: reporting ---- + Add(TEXT("vp.DumpState"), TEXT("Dump runtime streaming stats."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { if (auto* S = GetSub(W)) { S->DumpRuntimeStats(); } })); + Add(TEXT("vp.DumpCells"), TEXT("Dump every cell's state."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { if (auto* S = GetSub(W)) { S->DumpCells(); } })); + Add(TEXT("vp.DumpWarnings"), TEXT("Dump build warnings/errors from the descriptor."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { if (auto* S = GetSub(W)) { S->DumpWarnings(); } })); + Add(TEXT("vp.ProfileStreaming"), TEXT("Log a one-shot streaming profile snapshot."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { if (auto* S = GetSub(W)) { S->DumpRuntimeStats(); S->DumpCells(); } })); + + // ---- runtime: forcing / overrides ---- + Add(TEXT("vp.ForceCellFull"), TEXT("vp.ForceCellFull - pin a cell to Full."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 0) { S->ForceCellFull(FCString::Atoi(*A[0])); } } })); + Add(TEXT("vp.ForceCellHLOD"), TEXT("vp.ForceCellHLOD - pin a cell to HLOD."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 0) { S->ForceCellHLOD(FCString::Atoi(*A[0])); } } })); + Add(TEXT("vp.UnforceCell"), TEXT("vp.UnforceCell - release a forced cell."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 0) { S->UnforceCell(FCString::Atoi(*A[0])); } } })); + Add(TEXT("vp.UnforceAll"), TEXT("Release all forced cells and ShowOnly mode."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { if (auto* S = GetSub(W)) { S->UnforceAll(); } })); + Add(TEXT("vp.ShowOnlyCell"), TEXT("vp.ShowOnlyCell - Full only this cell, unload the rest."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 0) { S->ShowOnlyCell(FCString::Atoi(*A[0])); } } })); + Add(TEXT("vp.SetFullRange"), TEXT("vp.SetFullRange ."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 1) { S->SetFullRange(FCString::Atoi(*A[0]), FCString::Atoi(*A[1])); } } })); + Add(TEXT("vp.SetHLODRange"), TEXT("vp.SetHLODRange ."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 1) { S->SetHLODRange(FCString::Atoi(*A[0]), FCString::Atoi(*A[1])); } } })); + Add(TEXT("vp.SetCellHeight"), TEXT("vp.SetCellHeight (affects prediction only at runtime)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* S = GetSub(W)) { if (A.Num() > 0) { S->SetCellHeight(FCString::Atof(*A[0])); } } })); + Add(TEXT("vp.TeleportToCell"), TEXT("vp.TeleportToCell - move the tracked pawn to a cell."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray& A, UWorld* W) + { if (auto* M = FindManager(W)) { if (A.Num() > 0) { M->TeleportToCell(FCString::Atoi(*A[0])); } } })); + + // ---- editor/build (routed via the bridge) ---- + Add(TEXT("vp.Analyze"), TEXT("Analyze the current level (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::Analyze, TEXT("vp.Analyze")); })); + Add(TEXT("vp.BuildFromSelectedVolume"), TEXT("Build using the selected/first volume (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::Build, TEXT("vp.BuildFromSelectedVolume")); })); + Add(TEXT("vp.Validate"), TEXT("Validate cells against the level (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::Validate, TEXT("vp.Validate")); })); + Add(TEXT("vp.Rebuild"), TEXT("Rebuild changed cells (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::RebuildChangedCells, TEXT("vp.Rebuild")); })); + Add(TEXT("vp.RebuildHLOD"), TEXT("Rebuild HLOD proxies (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::RebuildHLOD, TEXT("vp.RebuildHLOD")); })); + Add(TEXT("vp.PreviewChunks"), TEXT("Draw coloured cell boxes in the editor viewport (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::PreviewChunks, TEXT("vp.PreviewChunks")); })); + Add(TEXT("vp.PreviewHLOD"), TEXT("Spawn baked HLOD proxy meshes in the editor viewport (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::SpawnHLODPreview, TEXT("vp.PreviewHLOD")); })); + Add(TEXT("vp.PreviewClear"), TEXT("Clear preview boxes + spawned HLOD preview actors (editor)."), + FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray&, UWorld* W) + { RouteEditor(W, EVerticalPartitionAction::ClearPreview, TEXT("vp.PreviewClear")); })); + } + + virtual void ShutdownModule() override + { + IConsoleManager& CM = IConsoleManager::Get(); + for (IConsoleCommand* Cmd : Commands) + { + if (Cmd) { CM.UnregisterConsoleObject(Cmd); } + } + Commands.Reset(); + } + +private: + TArray Commands; +}; + +IMPLEMENT_MODULE(FVerticalPartitionModule, VerticalPartition); diff --git a/Source/VerticalPartition/Private/VerticalPartitionRuntimeSubsystem.cpp b/Source/VerticalPartition/Private/VerticalPartitionRuntimeSubsystem.cpp new file mode 100644 index 0000000..560050a --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionRuntimeSubsystem.cpp @@ -0,0 +1,632 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionRuntimeSubsystem.h" +#include "VerticalPartitionDescriptor.h" +#include "VerticalPartitionSettings.h" +#include "VerticalPartitionLog.h" +#include "Engine/World.h" +#include "Engine/Level.h" +#include "Engine/LevelStreaming.h" +#include "Engine/LevelStreamingDynamic.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Components/StaticMeshComponent.h" +#include "GameFramework/Actor.h" + +UVerticalPartitionRuntimeSubsystem* UVerticalPartitionRuntimeSubsystem::Get(const UObject* WorldContextObject) +{ + if (!WorldContextObject) + { + return nullptr; + } + if (const UWorld* World = WorldContextObject->GetWorld()) + { + return World->GetSubsystem(); + } + return nullptr; +} + +void UVerticalPartitionRuntimeSubsystem::Deinitialize() +{ + UnregisterDescriptor(); + Super::Deinitialize(); +} + +void UVerticalPartitionRuntimeSubsystem::RegisterDescriptor(UVerticalPartitionDescriptor* InDescriptor, const FBox& InVolumeBounds) +{ + UnregisterDescriptor(); + + Descriptor = InDescriptor; + if (!Descriptor) + { + return; + } + + EffectiveSettings = Descriptor->Settings; + VolumeBounds = InVolumeBounds.IsValid ? InVolumeBounds : Descriptor->VolumeBounds; + + Cells.Reset(); + Cells.Reserve(Descriptor->Cells.Num()); + + for (int32 i = 0; i < Descriptor->Cells.Num(); ++i) + { + const FVerticalCellDescriptor& D = Descriptor->Cells[i]; + + FVerticalCellRuntime RT; + RT.Id = D.CellId; + RT.DescriptorIndex = i; + RT.Bounds = D.Bounds; + RT.EstimatedMemoryMB = D.EstimatedMemoryMB; + RT.bUsesStreamingBackend = D.FullCellPackage.IsValid(); + + if (!RT.bUsesStreamingBackend) + { + // NonDestructive: the actors are already in the persistent level. Resolve + // them once and start from "Full" (they are currently visible). + RT.ResolvedActors.Reserve(D.SourceActors.Num()); + for (const FSoftObjectPath& Path : D.SourceActors) + { + if (AActor* Actor = Cast(Path.ResolveObject())) + { + RT.ResolvedActors.Add(Actor); + } + } + RT.State = EVerticalCellState::Full; + } + else + { + RT.State = EVerticalCellState::Unloaded; + } + + Cells.Add(RT.Id, MoveTemp(RT)); + } + + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Registered descriptor '%s' with %d cells (%s backend)."), + *Descriptor->GetName(), Cells.Num(), Descriptor->HasFullPackages() ? TEXT("streaming") : TEXT("visibility")); +} + +void UVerticalPartitionRuntimeSubsystem::UnregisterDescriptor() +{ + for (TPair& Pair : Cells) + { + FVerticalCellRuntime& C = Pair.Value; + // Restore persistent actors so the level looks normal after PIE/unregister. + if (!C.bUsesStreamingBackend) + { + SetActorGroupActive(C, true, true, true); + } + if (C.ProxyActor.IsValid()) + { + C.ProxyActor->Destroy(); + } + if (C.StreamingLevel.IsValid()) + { + C.StreamingLevel->SetShouldBeVisible(false); + C.StreamingLevel->SetShouldBeLoaded(false); + } + } + Cells.Reset(); + Descriptor = nullptr; + Stats = FVerticalRuntimeStats(); + bShowOnlyMode = false; +} + +// --------------------------------------------------------------------------- +// Main update +// --------------------------------------------------------------------------- + +void UVerticalPartitionRuntimeSubsystem::UpdateStreaming(const FVerticalStreamingContext& Ctx, float /*DeltaSeconds*/, bool bSnap) +{ + if (!Descriptor || Cells.Num() == 0) + { + return; + } + + const double T0 = FPlatformTime::Seconds(); + Stats.PlayerCellZ = Ctx.PlayerCellZ; + + // 1) Desired state for every cell. + for (TPair& Pair : Cells) + { + FVerticalCellRuntime& C = Pair.Value; + PollStreaming(C); // refresh State from in-flight async ops first + + if (C.bForced) + { + C.Desired = C.ForcedState; + } + else if (bShowOnlyMode) + { + C.Desired = (C.Id.Z == ShowOnlyZ) ? EVerticalCellDesiredState::Full : EVerticalCellDesiredState::Unloaded; + } + else + { + C.Desired = UVerticalPartitionStatics::GetDesiredStateForCell(C.Id, Ctx, EffectiveSettings, C.State); + } + + C.bPreloadRequested = (C.Desired == EVerticalCellDesiredState::HLOD) + && UVerticalPartitionStatics::ShouldPreloadFull(C.Id, Ctx, EffectiveSettings); + } + + // 2) Enforce budgets: keep the nearest MaxFullCells as Full, nearest + // MaxVisibleHLODCells as HLOD, demote the rest. + { + TArray Ordered; + Ordered.Reserve(Cells.Num()); + for (TPair& Pair : Cells) + { + Ordered.Add(&Pair.Value); + } + const int32 PlayerZ = Ctx.PlayerCellZ; + Ordered.Sort([PlayerZ](const FVerticalCellRuntime& A, const FVerticalCellRuntime& B) + { + return FMath::Abs(A.Id.Z - PlayerZ) < FMath::Abs(B.Id.Z - PlayerZ); + }); + + int32 FullCount = 0; + int32 HlodCount = 0; + const int32 MaxFull = FMath::Max(0, EffectiveSettings.MaxFullCells); + const int32 MaxHlod = FMath::Max(0, EffectiveSettings.MaxVisibleHLODCells); + for (FVerticalCellRuntime* C : Ordered) + { + if (C->bForced) { continue; } // forced cells ignore budgets + if (C->Desired == EVerticalCellDesiredState::Full) + { + if (FullCount < MaxFull) { ++FullCount; } + else { C->Desired = EVerticalCellDesiredState::HLOD; } + } + if (C->Desired == EVerticalCellDesiredState::HLOD) + { + if (HlodCount < MaxHlod) { ++HlodCount; } + else { C->Desired = EVerticalCellDesiredState::Unloaded; } + } + } + + // 3) Apply transitions, nearest first, within the async budget. + int32 LoadBudget = bSnap ? MAX_int32 : FMath::Max(1, EffectiveSettings.MaxAsyncLoadsPerFrame); + int32 UnloadBudget = bSnap ? MAX_int32 : FMath::Max(1, EffectiveSettings.MaxAsyncUnloadsPerFrame); + for (FVerticalCellRuntime* C : Ordered) + { + ApplyDesiredState(*C, bSnap, LoadBudget, UnloadBudget); + } + } + + RebuildStatsFromCells(); + Stats.LastUpdateTimeMS = (float)((FPlatformTime::Seconds() - T0) * 1000.0); + Stats.LastStreamingTimeMS = Stats.LastUpdateTimeMS; + + if (const UVerticalPartitionDeveloperSettings* Project = GetDefault()) + { + if (Stats.LastUpdateTimeMS > Project->HitchThresholdMS) + { + ++Stats.HitchesOverThreshold; + UE_LOG(LogVerticalPartition, Verbose, TEXT("[VP] Streaming update hitch: %.2f ms"), Stats.LastUpdateTimeMS); + } + } +} + +void UVerticalPartitionRuntimeSubsystem::ApplyDesiredState(FVerticalCellRuntime& C, bool bSnap, int32& LoadBudget, int32& UnloadBudget) +{ + switch (C.Desired) + { + case EVerticalCellDesiredState::Full: + if (C.bUsesStreamingBackend) + { + if (C.State != EVerticalCellState::Full) + { + if (LoadBudget > 0) + { + LoadCellFullAsync(C, /*bVisible*/true); + --LoadBudget; + } + } + else + { + HideProxy(C); + } + } + else + { + SetActorGroupActive(C, /*bVisible*/true, /*bCollision*/true, /*bTick*/true); + HideProxy(C); + C.State = EVerticalCellState::Full; + } + break; + + case EVerticalCellDesiredState::HLOD: + ShowCellHLOD(C); + if (C.bUsesStreamingBackend) + { + if (C.bPreloadRequested) + { + if (C.State != EVerticalCellState::HLOD && C.State != EVerticalCellState::PreloadingFull && LoadBudget > 0) + { + LoadCellFullAsync(C, /*bVisible*/false); // resident, hidden + --LoadBudget; + } + } + else if (C.StreamingLevel.IsValid() && UnloadBudget > 0) + { + C.StreamingLevel->SetShouldBeVisible(false); + C.StreamingLevel->SetShouldBeLoaded(false); // free full data while showing proxy + --UnloadBudget; + } + if (C.State == EVerticalCellState::Full || C.State == EVerticalCellState::Unloaded) + { + C.State = EVerticalCellState::HLOD; + } + } + else + { + SetActorGroupActive(C, /*bVisible*/false, /*bCollision*/false, /*bTick*/false); + C.State = EVerticalCellState::HLOD; + } + break; + + case EVerticalCellDesiredState::Unloaded: + default: + if (C.bUsesStreamingBackend) + { + if (C.State != EVerticalCellState::Unloaded && UnloadBudget > 0) + { + UnloadCell(C); + --UnloadBudget; + } + } + else + { + UnloadCell(C); + } + break; + } +} + +void UVerticalPartitionRuntimeSubsystem::LoadCellFullAsync(FVerticalCellRuntime& C, bool bVisible) +{ + ULevelStreaming* L = EnsureStreamingLevel(C); + if (!L) + { + C.State = EVerticalCellState::Error; + return; + } + L->SetShouldBeLoaded(true); + L->SetShouldBeVisible(bVisible); + C.State = bVisible ? EVerticalCellState::LoadingFull : EVerticalCellState::PreloadingFull; +} + +void UVerticalPartitionRuntimeSubsystem::ShowCellHLOD(FVerticalCellRuntime& C) +{ + if (AStaticMeshActor* Proxy = EnsureProxy(C)) + { + Proxy->SetActorHiddenInGame(false); + } +} + +void UVerticalPartitionRuntimeSubsystem::UnloadCell(FVerticalCellRuntime& C) +{ + if (C.bUsesStreamingBackend) + { + if (C.StreamingLevel.IsValid()) + { + C.StreamingLevel->SetShouldBeVisible(false); + C.StreamingLevel->SetShouldBeLoaded(false); + C.State = EVerticalCellState::Unloading; + } + else + { + C.State = EVerticalCellState::Unloaded; + } + } + else + { + SetActorGroupActive(C, /*bVisible*/false, /*bCollision*/false, /*bTick*/false); + C.State = EVerticalCellState::Unloaded; + } + HideProxy(C); +} + +void UVerticalPartitionRuntimeSubsystem::PollStreaming(FVerticalCellRuntime& C) +{ + if (!C.bUsesStreamingBackend || !C.StreamingLevel.IsValid()) + { + return; + } + ULevelStreaming* L = C.StreamingLevel.Get(); + const ELevelStreamingState St = L->GetLevelStreamingState(); + const bool bVisible = (St == ELevelStreamingState::LoadedVisible); + const bool bLoaded = bVisible + || St == ELevelStreamingState::LoadedNotVisible + || St == ELevelStreamingState::MakingVisible + || St == ELevelStreamingState::MakingInvisible; + + if (St == ELevelStreamingState::FailedToLoad) + { + C.State = EVerticalCellState::Error; + return; + } + + if (C.Desired == EVerticalCellDesiredState::Full) + { + if (bVisible) { C.State = EVerticalCellState::Full; HideProxy(C); } + else { C.State = EVerticalCellState::LoadingFull; } + } + else if (C.Desired == EVerticalCellDesiredState::HLOD) + { + C.State = (C.bPreloadRequested && !bLoaded) ? EVerticalCellState::PreloadingFull : EVerticalCellState::HLOD; + } + else + { + C.State = bLoaded ? EVerticalCellState::Unloading : EVerticalCellState::Unloaded; + } +} + +// --------------------------------------------------------------------------- +// Backend helpers +// --------------------------------------------------------------------------- + +void UVerticalPartitionRuntimeSubsystem::SetActorGroupActive(FVerticalCellRuntime& C, bool bVisible, bool bCollision, bool bTick) +{ + for (const TWeakObjectPtr& Weak : C.ResolvedActors) + { + if (AActor* Actor = Weak.Get()) + { + Actor->SetActorHiddenInGame(!bVisible); + Actor->SetActorEnableCollision(bCollision); + Actor->SetActorTickEnabled(bTick); + } + } +} + +AStaticMeshActor* UVerticalPartitionRuntimeSubsystem::EnsureProxy(FVerticalCellRuntime& C) +{ + if (C.ProxyActor.IsValid()) + { + return C.ProxyActor.Get(); + } + if (!Descriptor || !Descriptor->Cells.IsValidIndex(C.DescriptorIndex)) + { + return nullptr; + } + const FVerticalCellDescriptor& D = Descriptor->Cells[C.DescriptorIndex]; + if (!D.HLODProxyAsset.IsValid()) + { + if (!C.bProxyMissingWarned) + { + UE_LOG(LogVerticalPartition, Verbose, TEXT("[VP] Cell %s has no HLOD proxy; far cell will simply hide."), *C.Id.ToString()); + C.bProxyMissingWarned = true; + } + return nullptr; + } + + UStaticMesh* Mesh = Cast(D.HLODProxyAsset.TryLoad()); + UWorld* World = GetWorld(); + if (!Mesh || !World) + { + return nullptr; + } + + FActorSpawnParameters Params; + Params.ObjectFlags |= RF_Transient; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + // Proxy meshes are baked with bPivotPointAtZero (world-space verts), so the actor + // goes at the world origin and the geometry lands exactly over the source actors. + AStaticMeshActor* Actor = World->SpawnActor(FVector::ZeroVector, FRotator::ZeroRotator, Params); + if (!Actor) + { + return nullptr; + } + if (UStaticMeshComponent* Comp = Actor->GetStaticMeshComponent()) + { + Comp->SetMobility(EComponentMobility::Movable); + Comp->SetStaticMesh(Mesh); + Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision); + Comp->SetCanEverAffectNavigation(false); + } + Actor->SetActorHiddenInGame(true); + C.ProxyActor = Actor; + return Actor; +} + +void UVerticalPartitionRuntimeSubsystem::HideProxy(FVerticalCellRuntime& C) +{ + if (C.ProxyActor.IsValid()) + { + C.ProxyActor->SetActorHiddenInGame(true); + } +} + +ULevelStreaming* UVerticalPartitionRuntimeSubsystem::EnsureStreamingLevel(FVerticalCellRuntime& C) +{ + if (C.StreamingLevel.IsValid()) + { + return C.StreamingLevel.Get(); + } + UWorld* World = GetWorld(); + if (!World || !Descriptor || !Descriptor->Cells.IsValidIndex(C.DescriptorIndex)) + { + return nullptr; + } + const FVerticalCellDescriptor& D = Descriptor->Cells[C.DescriptorIndex]; + const FString PackageName = D.FullCellPackage.GetLongPackageName(); + if (PackageName.IsEmpty()) + { + return nullptr; + } + const FName PackageFName(*PackageName); + + for (ULevelStreaming* Existing : World->GetStreamingLevels()) + { + if (Existing && Existing->GetWorldAssetPackageFName() == PackageFName) + { + C.StreamingLevel = Existing; + return Existing; + } + } + + ULevelStreamingDynamic* NewStream = NewObject(World, ULevelStreamingDynamic::StaticClass(), NAME_None, RF_Transient); + NewStream->SetWorldAssetByPackageName(PackageFName); + NewStream->SetShouldBeLoaded(false); + NewStream->SetShouldBeVisible(false); + NewStream->bShouldBlockOnLoad = false; + World->AddStreamingLevel(NewStream); + C.StreamingLevel = NewStream; + return NewStream; +} + +void UVerticalPartitionRuntimeSubsystem::RebuildStatsFromCells() +{ + Stats.FullCellsLoaded = 0; + Stats.HLODCellsVisible = 0; + Stats.CellsUnloaded = 0; + Stats.CellsLoading = 0; + Stats.ActiveAsyncLoads = 0; + Stats.EstimatedMemoryMB = 0.f; + + for (const TPair& Pair : Cells) + { + const FVerticalCellRuntime& C = Pair.Value; + switch (C.State) + { + case EVerticalCellState::Full: + ++Stats.FullCellsLoaded; + Stats.EstimatedMemoryMB += C.EstimatedMemoryMB; + break; + case EVerticalCellState::HLOD: + ++Stats.HLODCellsVisible; + break; + case EVerticalCellState::LoadingFull: + case EVerticalCellState::LoadingHLOD: + case EVerticalCellState::PreloadingFull: + ++Stats.CellsLoading; + ++Stats.ActiveAsyncLoads; + Stats.EstimatedMemoryMB += C.EstimatedMemoryMB; // resident while preloading + break; + case EVerticalCellState::Unloading: + ++Stats.CellsLoading; + break; + case EVerticalCellState::Unloaded: + default: + ++Stats.CellsUnloaded; + break; + } + } +} + +FVerticalCellRuntime* UVerticalPartitionRuntimeSubsystem::FindCellByZ(int32 Z) +{ + for (TPair& Pair : Cells) + { + if (Pair.Value.Id.Z == Z) + { + return &Pair.Value; + } + } + return nullptr; +} + +// --------------------------------------------------------------------------- +// Live overrides + debug forcing +// --------------------------------------------------------------------------- + +void UVerticalPartitionRuntimeSubsystem::SetFullRange(int32 Below, int32 Above) +{ + EffectiveSettings.FullLoadCellsBelow = FMath::Max(0, Below); + EffectiveSettings.FullLoadCellsAbove = FMath::Max(0, Above); + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Full range set to below=%d above=%d"), Below, Above); +} + +void UVerticalPartitionRuntimeSubsystem::SetHLODRange(int32 Below, int32 Above) +{ + EffectiveSettings.HLODLoadCellsBelow = FMath::Max(0, Below); + EffectiveSettings.HLODLoadCellsAbove = FMath::Max(0, Above); + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] HLOD range set to below=%d above=%d"), Below, Above); +} + +void UVerticalPartitionRuntimeSubsystem::SetCellHeight(float NewHeight) +{ + EffectiveSettings.CellHeight = FMath::Max(1.f, NewHeight); + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] CellHeight changed at runtime to %.0f. Note: cell boundaries are baked in the descriptor; this only affects velocity prediction. Rebuild to re-segment."), NewHeight); +} + +void UVerticalPartitionRuntimeSubsystem::ForceCellFull(int32 Z) +{ + for (TPair& Pair : Cells) + { + if (Pair.Value.Id.Z == Z) { Pair.Value.bForced = true; Pair.Value.ForcedState = EVerticalCellDesiredState::Full; } + } +} + +void UVerticalPartitionRuntimeSubsystem::ForceCellHLOD(int32 Z) +{ + for (TPair& Pair : Cells) + { + if (Pair.Value.Id.Z == Z) { Pair.Value.bForced = true; Pair.Value.ForcedState = EVerticalCellDesiredState::HLOD; } + } +} + +void UVerticalPartitionRuntimeSubsystem::UnforceCell(int32 Z) +{ + for (TPair& Pair : Cells) + { + if (Pair.Value.Id.Z == Z) { Pair.Value.bForced = false; } + } +} + +void UVerticalPartitionRuntimeSubsystem::UnforceAll() +{ + for (TPair& Pair : Cells) + { + Pair.Value.bForced = false; + } + bShowOnlyMode = false; +} + +void UVerticalPartitionRuntimeSubsystem::ShowOnlyCell(int32 Z) +{ + bShowOnlyMode = true; + ShowOnlyZ = Z; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +void UVerticalPartitionRuntimeSubsystem::DumpRuntimeStats() const +{ + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] ==== Runtime Stats ====")); + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] PlayerCellZ=%d Full=%d HLOD=%d Unloaded=%d Loading=%d Async=%d Mem~%.1fMB Update=%.2fms Hitches=%d"), + Stats.PlayerCellZ, Stats.FullCellsLoaded, Stats.HLODCellsVisible, Stats.CellsUnloaded, + Stats.CellsLoading, Stats.ActiveAsyncLoads, Stats.EstimatedMemoryMB, Stats.LastUpdateTimeMS, Stats.HitchesOverThreshold); +} + +void UVerticalPartitionRuntimeSubsystem::DumpCells() const +{ + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] ==== Cells (%d) ===="), Cells.Num()); + for (const TPair& Pair : Cells) + { + const FVerticalCellRuntime& C = Pair.Value; + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] %s state=%d desired=%d actors=%d backend=%s forced=%d"), + *C.Id.ToString(), (int32)C.State, (int32)C.Desired, C.ResolvedActors.Num(), + C.bUsesStreamingBackend ? TEXT("stream") : TEXT("vis"), C.bForced ? 1 : 0); + } +} + +void UVerticalPartitionRuntimeSubsystem::DumpWarnings() const +{ + if (!Descriptor) + { + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] No descriptor registered.")); + return; + } + const FVerticalPartitionBuildReport& R = Descriptor->LastReport; + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] ==== Build Warnings (%d) / Errors (%d) ===="), R.Warnings.Num(), R.Errors.Num()); + for (const FVerticalPartitionWarning& W : R.Warnings) + { + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] WARN %s :: %s"), + *UVerticalPartitionStatics::WarningTypeToString(W.Type), *W.Message); + } + for (const FVerticalPartitionError& E : R.Errors) + { + UE_LOG(LogVerticalPartition, Error, TEXT("[VP] ERR %s :: %s"), + *UVerticalPartitionStatics::WarningTypeToString(E.Type), *E.Message); + } +} diff --git a/Source/VerticalPartition/Private/VerticalPartitionSettings.cpp b/Source/VerticalPartition/Private/VerticalPartitionSettings.cpp new file mode 100644 index 0000000..86b12fa --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionSettings.cpp @@ -0,0 +1,12 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionSettings.h" + +UVerticalPartitionDeveloperSettings::UVerticalPartitionDeveloperSettings() +{ + // DefaultSettings keeps the struct defaults declared in VerticalPartitionTypes.h. +} + +const UVerticalPartitionDeveloperSettings* UVerticalPartitionDeveloperSettings::Get() +{ + return GetDefault(); +} diff --git a/Source/VerticalPartition/Private/VerticalPartitionStatics.cpp b/Source/VerticalPartition/Private/VerticalPartitionStatics.cpp new file mode 100644 index 0000000..ad7c1e7 --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionStatics.cpp @@ -0,0 +1,257 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionStatics.h" + +int32 UVerticalPartitionStatics::GetCellZFromWorldZ(float WorldZ, float VolumeMinZ, float CellHeight) +{ + if (CellHeight <= KINDA_SMALL_NUMBER) + { + return 0; + } + return FMath::FloorToInt((WorldZ - VolumeMinZ) / CellHeight); +} + +int32 UVerticalPartitionStatics::GetCellXFromWorldX(float WorldX, float VolumeMinX, float XYCellSize) +{ + if (XYCellSize <= KINDA_SMALL_NUMBER) + { + return 0; + } + return FMath::FloorToInt((WorldX - VolumeMinX) / XYCellSize); +} + +int32 UVerticalPartitionStatics::GetCellYFromWorldY(float WorldY, float VolumeMinY, float XYCellSize) +{ + if (XYCellSize <= KINDA_SMALL_NUMBER) + { + return 0; + } + return FMath::FloorToInt((WorldY - VolumeMinY) / XYCellSize); +} + +int32 UVerticalPartitionStatics::GetNumZCells(const FBox& VolumeBounds, const FVerticalPartitionSettings& S) +{ + if (!VolumeBounds.IsValid || S.CellHeight <= KINDA_SMALL_NUMBER) + { + return 1; + } + const float SizeZ = VolumeBounds.Max.Z - VolumeBounds.Min.Z; + return FMath::Max(1, FMath::CeilToInt(SizeZ / S.CellHeight)); +} + +FVerticalCellId UVerticalPartitionStatics::GetCellIdFromPoint(const FVector& Point, const FBox& VolumeBounds, const FVerticalPartitionSettings& S) +{ + FVerticalCellId Id; + Id.Z = GetCellZFromWorldZ(Point.Z, VolumeBounds.Min.Z, S.CellHeight); + if (S.bUseXYSubCells) + { + Id.X = GetCellXFromWorldX(Point.X, VolumeBounds.Min.X, S.XYCellSize); + Id.Y = GetCellYFromWorldY(Point.Y, VolumeBounds.Min.Y, S.XYCellSize); + } + + if (S.bClampToVolumeBounds && VolumeBounds.IsValid) + { + const int32 NumZ = GetNumZCells(VolumeBounds, S); + Id.Z = FMath::Clamp(Id.Z, 0, NumZ - 1); + if (S.bUseXYSubCells && S.XYCellSize > KINDA_SMALL_NUMBER) + { + const int32 NumX = FMath::Max(1, FMath::CeilToInt((VolumeBounds.Max.X - VolumeBounds.Min.X) / S.XYCellSize)); + const int32 NumY = FMath::Max(1, FMath::CeilToInt((VolumeBounds.Max.Y - VolumeBounds.Min.Y) / S.XYCellSize)); + Id.X = FMath::Clamp(Id.X, 0, NumX - 1); + Id.Y = FMath::Clamp(Id.Y, 0, NumY - 1); + } + } + return Id; +} + +FVerticalCellId UVerticalPartitionStatics::GetCellIdFromBounds(const FBox& ActorBounds, const FBox& VolumeBounds, const FVerticalPartitionSettings& S, bool& bOutCrossesMultiple) +{ + bOutCrossesMultiple = false; + if (!ActorBounds.IsValid) + { + return FVerticalCellId(); + } + + // Dominant cell = the cell containing the bounds centre (deterministic). + const FVerticalCellId Id = GetCellIdFromPoint(ActorBounds.GetCenter(), VolumeBounds, S); + + // Does the box span more than one cell on any partitioned axis? + const int32 MinZ = GetCellZFromWorldZ(ActorBounds.Min.Z, VolumeBounds.Min.Z, S.CellHeight); + const int32 MaxZ = GetCellZFromWorldZ(ActorBounds.Max.Z, VolumeBounds.Min.Z, S.CellHeight); + bOutCrossesMultiple = (MinZ != MaxZ); + + if (S.bUseXYSubCells && !bOutCrossesMultiple) + { + const int32 MinX = GetCellXFromWorldX(ActorBounds.Min.X, VolumeBounds.Min.X, S.XYCellSize); + const int32 MaxX = GetCellXFromWorldX(ActorBounds.Max.X, VolumeBounds.Min.X, S.XYCellSize); + const int32 MinY = GetCellYFromWorldY(ActorBounds.Min.Y, VolumeBounds.Min.Y, S.XYCellSize); + const int32 MaxY = GetCellYFromWorldY(ActorBounds.Max.Y, VolumeBounds.Min.Y, S.XYCellSize); + bOutCrossesMultiple = (MinX != MaxX) || (MinY != MaxY); + } + + return Id; +} + +FBox UVerticalPartitionStatics::GetCellBounds(const FVerticalCellId& Cell, const FBox& VolumeBounds, const FVerticalPartitionSettings& S) +{ + FVector Min = VolumeBounds.Min; + FVector Max = VolumeBounds.Max; + + Min.Z = VolumeBounds.Min.Z + Cell.Z * S.CellHeight; + Max.Z = Min.Z + S.CellHeight; + + if (S.bUseXYSubCells && S.XYCellSize > KINDA_SMALL_NUMBER) + { + Min.X = VolumeBounds.Min.X + Cell.X * S.XYCellSize; + Max.X = Min.X + S.XYCellSize; + Min.Y = VolumeBounds.Min.Y + Cell.Y * S.XYCellSize; + Max.Y = Min.Y + S.XYCellSize; + } + + FBox Result(Min, Max); + if (S.bClampToVolumeBounds && VolumeBounds.IsValid) + { + Result = Result.Overlap(VolumeBounds); + if (!Result.IsValid) + { + Result = FBox(Min, Max); // degenerate clamp: keep nominal bounds + } + } + return Result; +} + +// Recompute the predicted player Z cell (shared by desired-state + preload). +static int32 EffectivePlayerZ(const FVerticalStreamingContext& Ctx, const FVerticalPartitionSettings& S) +{ + int32 Eff = Ctx.PlayerCellZ; + if (S.bUsePlayerVelocityPrediction && S.CellHeight > KINDA_SMALL_NUMBER) + { + Eff += FMath::RoundToInt((Ctx.PlayerVelocityZ * S.VelocityPredictionSeconds) / S.CellHeight); + } + return Eff; +} + +EVerticalCellDesiredState UVerticalPartitionStatics::GetDesiredStateForCell( + const FVerticalCellId& Cell, const FVerticalStreamingContext& Ctx, + const FVerticalPartitionSettings& S, EVerticalCellState CurrentState) +{ + const int32 EffPlayerZ = EffectivePlayerZ(Ctx, S); + const int32 Dz = Cell.Z - EffPlayerZ; // +above the player + + int32 FullAbove = S.FullLoadCellsAbove; + int32 FullBelow = S.FullLoadCellsBelow; + int32 HlodAbove = S.HLODLoadCellsAbove; + int32 HlodBelow = S.HLODLoadCellsBelow; + + // Bias the reach upward: in a climbing map the route is above you. + if (S.bPrioritizeAbovePlayer) + { + HlodAbove += 1; + } + // Look up -> extend upward; look down -> extend downward. + if (S.bUseCameraDirectionPriority) + { + if (Ctx.CameraPitchDeg > 15.f) { FullAbove += 1; HlodAbove += 1; } + else if (Ctx.CameraPitchDeg < -15.f) { HlodBelow += 1; } + } + // You can always fall back down: keep the cells below loaded out to the unload edge. + if (S.bKeepBelowPlayerLoadedOnFallRisk) + { + HlodBelow = FMath::Max(HlodBelow, S.UnloadCellsBelow); + } + + const int32 Hys = FMath::Max(0, S.HysteresisCells); + const bool bCurFullish = (CurrentState == EVerticalCellState::Full + || CurrentState == EVerticalCellState::LoadingFull + || CurrentState == EVerticalCellState::PreloadingFull); + const bool bCurHlodish = (CurrentState == EVerticalCellState::HLOD + || CurrentState == EVerticalCellState::LoadingHLOD); + + EVerticalCellDesiredState Result; + if (Dz >= -FullBelow && Dz <= FullAbove) + { + Result = EVerticalCellDesiredState::Full; + } + else if (bCurFullish && Dz >= -(FullBelow + Hys) && Dz <= (FullAbove + Hys)) + { + Result = EVerticalCellDesiredState::Full; // sticky: don't drop a loaded cell at the edge + } + else if (Dz >= -HlodBelow && Dz <= HlodAbove) + { + Result = EVerticalCellDesiredState::HLOD; + } + else if (bCurHlodish && Dz >= -(HlodBelow + Hys) && Dz <= (HlodAbove + Hys)) + { + Result = EVerticalCellDesiredState::HLOD; + } + else + { + Result = EVerticalCellDesiredState::Unloaded; + } + + // XY refinement (optional): far columns never go full and drop out past 2 rings. + if (Ctx.bUseXY) + { + const int32 XYDist = FMath::Max(FMath::Abs(Cell.X - Ctx.PlayerCellX), FMath::Abs(Cell.Y - Ctx.PlayerCellY)); + if (XYDist > 2) + { + Result = EVerticalCellDesiredState::Unloaded; + } + else if (XYDist > 1 && Result == EVerticalCellDesiredState::Full) + { + Result = EVerticalCellDesiredState::HLOD; + } + } + + return Result; +} + +bool UVerticalPartitionStatics::ShouldPreloadFull(const FVerticalCellId& Cell, const FVerticalStreamingContext& Ctx, const FVerticalPartitionSettings& S) +{ + const int32 EffPlayerZ = EffectivePlayerZ(Ctx, S); + const int32 Dz = Cell.Z - EffPlayerZ; + const bool bAbovePreload = (Dz > S.FullLoadCellsAbove && Dz <= S.PreloadCellsAbove); + const bool bBelowPreload = (Dz < -S.FullLoadCellsBelow && Dz >= -S.PreloadCellsBelow); + return bAbovePreload || bBelowPreload; +} + +FLinearColor UVerticalPartitionStatics::GetStateColor(EVerticalCellState State) +{ + switch (State) + { + case EVerticalCellState::Full: return FLinearColor(0.10f, 0.85f, 0.15f); // green + case EVerticalCellState::HLOD: return FLinearColor(0.15f, 0.45f, 0.95f); // blue + case EVerticalCellState::LoadingHLOD: + case EVerticalCellState::LoadingFull: + case EVerticalCellState::PreloadingFull: + case EVerticalCellState::Unloading: return FLinearColor(0.95f, 0.85f, 0.10f); // yellow + case EVerticalCellState::Error: return FLinearColor(0.95f, 0.10f, 0.10f); // red + case EVerticalCellState::Unloaded: + default: return FLinearColor(0.35f, 0.35f, 0.35f); // gray + } +} + +FString UVerticalPartitionStatics::WarningTypeToString(EVerticalWarningType Type) +{ + switch (Type) + { + case EVerticalWarningType::ActorCrossesMultipleCells: return TEXT("Actor crosses multiple cells"); + case EVerticalWarningType::ActorTooLarge: return TEXT("Actor too large"); + case EVerticalWarningType::ActorSimulatesPhysics: return TEXT("Actor simulates physics"); + case EVerticalWarningType::ActorHasBlueprintTick: return TEXT("Actor has Blueprint tick"); + case EVerticalWarningType::ActorDynamicMobility: return TEXT("Actor has dynamic mobility"); + case EVerticalWarningType::ActorHasExternalReferences:return TEXT("Actor has external references"); + case EVerticalWarningType::ActorAttachedAcrossCells: return TEXT("Attached children in another cell"); + case EVerticalWarningType::ActorDependsOnOtherCell: return TEXT("Depends on actor in another cell"); + case EVerticalWarningType::ActorNeedsManualReview: return TEXT("Gameplay tag requires manual review"); + case EVerticalWarningType::ActorHasAudioLightNav: return TEXT("Contains audio/light/nav components"); + case EVerticalWarningType::ActorNotStreamSafe: return TEXT("Actor not safe for streaming"); + case EVerticalWarningType::ActorHasUnsavedChanges: return TEXT("Actor has unsaved changes"); + case EVerticalWarningType::CellTooManyActors: return TEXT("Cell has too many actors"); + case EVerticalWarningType::CellTooManyDrawCalls: return TEXT("Cell has too many draw calls"); + case EVerticalWarningType::HLODGenerationFailed: return TEXT("HLOD generation failed"); + case EVerticalWarningType::PackageSaveFailed: return TEXT("Package save failed"); + case EVerticalWarningType::NoVolume: return TEXT("No partition volume"); + case EVerticalWarningType::DescriptorOutOfDate: return TEXT("Descriptor out of date"); + default: return TEXT("None"); + } +} diff --git a/Source/VerticalPartition/Private/VerticalPartitionVolume.cpp b/Source/VerticalPartition/Private/VerticalPartitionVolume.cpp new file mode 100644 index 0000000..7c675d3 --- /dev/null +++ b/Source/VerticalPartition/Private/VerticalPartitionVolume.cpp @@ -0,0 +1,71 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#include "VerticalPartitionVolume.h" +#include "VerticalPartitionSettings.h" +#include "VerticalPartitionLog.h" +#include "Components/BoxComponent.h" + +#if WITH_EDITOR +TFunction FVerticalPartitionEditorBridge::Handler; +#endif + +AVerticalPartitionVolume::AVerticalPartitionVolume() +{ + PrimaryActorTick.bCanEverTick = false; + + Box = CreateDefaultSubobject(TEXT("Bounds")); + SetRootComponent(Box); + Box->SetBoxExtent(FVector(5000.f, 5000.f, 25000.f)); + Box->SetCollisionEnabled(ECollisionEnabled::NoCollision); + Box->SetCollisionProfileName(TEXT("NoCollision")); + Box->SetGenerateOverlapEvents(false); + Box->ShapeColor = FColor(80, 200, 255); + Box->bDrawOnlyIfSelected = false; + Box->SetHiddenInGame(true); + + // Seed per-volume settings from project defaults. + if (const UVerticalPartitionDeveloperSettings* Project = UVerticalPartitionDeveloperSettings::Get()) + { + Settings = Project->DefaultSettings; + } +} + +FBox AVerticalPartitionVolume::GetVolumeBounds() const +{ + if (!Box) + { + return FBox(ForceInit); + } + const FVector Extent = Box->GetScaledBoxExtent(); + const FVector Origin = Box->GetComponentLocation(); + return FBox(Origin - Extent, Origin + Extent); +} + +void AVerticalPartitionVolume::OnConstruction(const FTransform& Transform) +{ + Super::OnConstruction(Transform); +} + +#if WITH_EDITOR +void AVerticalPartitionVolume::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) +{ + Super::PostEditChangeProperty(PropertyChangedEvent); + // Keep preview drawing fresh when ranges/segmentation change. + if (Box) + { + Box->MarkRenderStateDirty(); + } +} + +void AVerticalPartitionVolume::AnalyzeCurrentLevel() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::Analyze); } +void AVerticalPartitionVolume::BuildVerticalPartition(){ FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::Build); } +void AVerticalPartitionVolume::RebuildChangedCells() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::RebuildChangedCells); } +void AVerticalPartitionVolume::RebuildHLOD() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::RebuildHLOD); } +void AVerticalPartitionVolume::ValidateCells() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::Validate); } +void AVerticalPartitionVolume::PreviewChunks() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::PreviewChunks); } +void AVerticalPartitionVolume::SpawnHLODPreview() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::SpawnHLODPreview); } +void AVerticalPartitionVolume::ClearPreview() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::ClearPreview); } +void AVerticalPartitionVolume::CommitConversion() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::Commit); } +void AVerticalPartitionVolume::RestoreFromBackup() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::RestoreBackup); } +void AVerticalPartitionVolume::ClearGeneratedData() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::ClearGenerated); } +void AVerticalPartitionVolume::DumpReport() { FVerticalPartitionEditorBridge::Execute(this, EVerticalPartitionAction::DumpReport); } +#endif diff --git a/Source/VerticalPartition/Public/VerticalPartitionDebugComponent.h b/Source/VerticalPartition/Public/VerticalPartitionDebugComponent.h new file mode 100644 index 0000000..d895bd5 --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionDebugComponent.h @@ -0,0 +1,31 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// World-Partition-style debug: coloured cell boxes, ids, actor counts, problem +// actor highlights, and an on-screen runtime overlay. Driven by the vp.Debug / +// vp.DebugDraw / vp.DebugOverlay console variables (and the volume's debug flags). +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "VerticalPartitionDebugComponent.generated.h" + +class UVerticalPartitionRuntimeSubsystem; + +UCLASS(ClassGroup=(VerticalPartition), meta=(BlueprintSpawnableComponent)) +class VERTICALPARTITION_API UVerticalPartitionDebugComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UVerticalPartitionDebugComponent(); + + virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; + + /** 3D coloured cell boxes + ids + counts (the spec's DrawDebugCells). */ + void DrawDebugCells(UVerticalPartitionRuntimeSubsystem* Sub) const; + /** On-screen text block of FVerticalRuntimeStats. */ + void DrawOverlay(UVerticalPartitionRuntimeSubsystem* Sub) const; + +private: + UVerticalPartitionRuntimeSubsystem* GetSub() const; +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionDescriptor.h b/Source/VerticalPartition/Public/VerticalPartitionDescriptor.h new file mode 100644 index 0000000..fe0c429 --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionDescriptor.h @@ -0,0 +1,50 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// The build artifact: a data asset describing every generated cell plus a snapshot +// of the settings and volume bounds the build was run with. The runtime streamer +// consumes this; the editor builder produces it. +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionDescriptor.generated.h" + +UCLASS(BlueprintType) +class VERTICALPARTITION_API UVerticalPartitionDescriptor : public UDataAsset +{ + GENERATED_BODY() + +public: + /** Map this descriptor was built from (for rebuild / out-of-date checks). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FSoftObjectPath SourceLevel; + + /** Settings snapshot used at build time (runtime ranges may be overridden live). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + FVerticalPartitionSettings Settings; + + /** World bounds of the partition volume at build time. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FBox VolumeBounds = FBox(ForceInit); + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + EVerticalConversionMode BuiltMode = EVerticalConversionMode::NonDestructive; + + /** Content hash of the source actors at build time (for incremental rebuild). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int64 SourceContentHash = 0; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + TArray Cells; + + /** Last build report (kept for the UI / Dump Report). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FVerticalPartitionBuildReport LastReport; + + /** Find a cell descriptor by id. Returns nullptr if absent. */ + const FVerticalCellDescriptor* FindCell(const FVerticalCellId& Id) const; + int32 FindCellIndex(const FVerticalCellId& Id) const; + + bool HasFullPackages() const; +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionLog.h b/Source/VerticalPartition/Public/VerticalPartitionLog.h new file mode 100644 index 0000000..1328e98 --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionLog.h @@ -0,0 +1,6 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +#pragma once + +#include "CoreMinimal.h" + +VERTICALPARTITION_API DECLARE_LOG_CATEGORY_EXTERN(LogVerticalPartition, Log, All); diff --git a/Source/VerticalPartition/Public/VerticalPartitionManager.h b/Source/VerticalPartition/Public/VerticalPartitionManager.h new file mode 100644 index 0000000..4b4a5fc --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionManager.h @@ -0,0 +1,62 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// In-world coordinator. One per streamed level (auto-spawned by the volume at +// BeginPlay, or placed manually). Determines the tracked player's current cell, +// drives the runtime subsystem at the configured interval, and hosts the debug +// component. Keep this lightweight: the heavy lifting is in the subsystem. +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionManager.generated.h" + +class UVerticalPartitionDescriptor; +class UVerticalPartitionDebugComponent; +class AVerticalPartitionVolume; + +UCLASS() +class VERTICALPARTITION_API AVerticalPartitionManager : public AActor +{ + GENERATED_BODY() + +public: + AVerticalPartitionManager(); + + /** Descriptor to stream. If unset, the manager searches the level for a volume. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + TSoftObjectPtr Descriptor; + + /** Optional explicit volume (otherwise the first one found is used). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + TObjectPtr Volume; + + /** Pawn whose position drives streaming. Defaults to local player pawn each tick. */ + UPROPERTY(BlueprintReadWrite, Category="VerticalPartition") + TWeakObjectPtr TrackedPawn; + + UPROPERTY(VisibleAnywhere, Category="VerticalPartition") + TObjectPtr DebugComponent; + + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + virtual void Tick(float DeltaSeconds) override; + + /** Snap streaming to the tracked pawn immediately (checkpoint / teleport). */ + UFUNCTION(BlueprintCallable, Category="VerticalPartition") + void RequestSnap() { bSnapNextUpdate = true; } + + /** Teleport the tracked pawn to the centre of a Z cell (debug). */ + UFUNCTION(BlueprintCallable, Category="VerticalPartition") + void TeleportToCell(int32 Z); + +private: + float UpdateAccumulator = 0.f; + bool bSnapNextUpdate = true; // snap on the first update + bool bHavePrevPos = false; + FVector PrevTrackedPos = FVector::ZeroVector; + FVector SmoothedVelocity = FVector::ZeroVector; + + APawn* ResolveTrackedPawn(); + bool BuildContext(struct FVerticalStreamingContext& OutCtx, float DeltaSeconds, bool& bOutTeleport); +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionRuntimeSubsystem.h b/Source/VerticalPartition/Public/VerticalPartitionRuntimeSubsystem.h new file mode 100644 index 0000000..e1888f2 --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionRuntimeSubsystem.h @@ -0,0 +1,119 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// The runtime streaming engine. World-scoped, holds the per-cell state machine, +// the async load queue, the proxy actors and the live statistics. Driven each +// update tick by AVerticalPartitionManager (subsystems are not ticked directly). +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/WorldSubsystem.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionStatics.h" +#include "VerticalPartitionRuntimeSubsystem.generated.h" + +class UVerticalPartitionDescriptor; +class ULevelStreaming; +class AStaticMeshActor; + +/** Per-cell live runtime record. Plain struct (C++-only, same-module access). */ +struct FVerticalCellRuntime +{ + FVerticalCellId Id; + int32 DescriptorIndex = INDEX_NONE; + FBox Bounds = FBox(ForceInit); + + EVerticalCellState State = EVerticalCellState::Unloaded; + EVerticalCellDesiredState Desired = EVerticalCellDesiredState::Unloaded; + + /** Backend B (DestructiveCommit): the streaming sub-level for the full actors. */ + TWeakObjectPtr StreamingLevel; + /** Backend A (NonDestructive): the original persistent-level actors of this cell. */ + TArray> ResolvedActors; + /** Shown while the cell is HLOD. */ + TWeakObjectPtr ProxyActor; + + bool bUsesStreamingBackend = false; // true => backend B + bool bPreloadRequested = false; + bool bProxyMissingWarned = false; + + /** Debug lock: when set, Desired is pinned to ForcedState. */ + bool bForced = false; + EVerticalCellDesiredState ForcedState = EVerticalCellDesiredState::Full; + + float EstimatedMemoryMB = 0.f; + double LastChangeTime = 0.0; +}; + +UCLASS() +class VERTICALPARTITION_API UVerticalPartitionRuntimeSubsystem : public UWorldSubsystem +{ + GENERATED_BODY() + +public: + static UVerticalPartitionRuntimeSubsystem* Get(const UObject* WorldContextObject); + + virtual void Deinitialize() override; + + // ---- lifecycle (called by the manager) ---- + void RegisterDescriptor(UVerticalPartitionDescriptor* InDescriptor, const FBox& InVolumeBounds); + void UnregisterDescriptor(); + bool IsReady() const { return Descriptor != nullptr; } + + /** Main entry point. Recomputes desired states and steps the machine within the + * per-update async budget. bSnap forces immediate convergence (teleport/checkpoint). */ + void UpdateStreaming(const FVerticalStreamingContext& Ctx, float DeltaSeconds, bool bSnap = false); + + // ---- live range overrides (console) ---- + FVerticalPartitionSettings& GetMutableSettings() { return EffectiveSettings; } + const FVerticalPartitionSettings& GetSettings() const { return EffectiveSettings; } + void SetFullRange(int32 Below, int32 Above); + void SetHLODRange(int32 Below, int32 Above); + void SetCellHeight(float NewHeight); + + // ---- debug forcing ---- + void ForceCellFull(int32 Z); + void ForceCellHLOD(int32 Z); + void UnforceCell(int32 Z); + void UnforceAll(); + void ShowOnlyCell(int32 Z); // force one Full, everything else Unloaded + + // ---- introspection ---- + const FVerticalRuntimeStats& GetStats() const { return Stats; } + const TMap& GetCellRuntimes() const { return Cells; } + const FBox& GetVolumeBounds() const { return VolumeBounds; } + UVerticalPartitionDescriptor* GetDescriptor() const { return Descriptor; } + int32 GetPlayerCellZ() const { return Stats.PlayerCellZ; } + + // ---- reporting (console) ---- + void DumpRuntimeStats() const; + void DumpCells() const; + void DumpWarnings() const; + +private: + UPROPERTY(Transient) + TObjectPtr Descriptor = nullptr; + + FVerticalPartitionSettings EffectiveSettings; + FBox VolumeBounds = FBox(ForceInit); + + TMap Cells; + FVerticalRuntimeStats Stats; + + bool bShowOnlyMode = false; + int32 ShowOnlyZ = 0; + + // ---- state-machine helpers (the spec's key runtime functions) ---- + void ApplyDesiredState(FVerticalCellRuntime& Cell, bool bSnap, int32& LoadBudget, int32& UnloadBudget); + void LoadCellFullAsync(FVerticalCellRuntime& Cell, bool bVisible); + void ShowCellHLOD(FVerticalCellRuntime& Cell); + void UnloadCell(FVerticalCellRuntime& Cell); + void PollStreaming(FVerticalCellRuntime& Cell); + + void SetActorGroupActive(FVerticalCellRuntime& Cell, bool bVisible, bool bCollision, bool bTick); + AStaticMeshActor* EnsureProxy(FVerticalCellRuntime& Cell); + void HideProxy(FVerticalCellRuntime& Cell); + ULevelStreaming* EnsureStreamingLevel(FVerticalCellRuntime& Cell); + + void RebuildStatsFromCells(); + FVerticalCellRuntime* FindCellByZ(int32 Z); // first cell at this Z (XY=0 path) +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionSettings.h b/Source/VerticalPartition/Public/VerticalPartitionSettings.h new file mode 100644 index 0000000..e106252 --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionSettings.h @@ -0,0 +1,36 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// Project-level defaults + global debug toggles. Appears under +// Project Settings -> Plugins -> Vertical Partition. Per-volume overrides live on +// AVerticalPartitionVolume; this provides the defaults a freshly placed volume gets. +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DeveloperSettings.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionSettings.generated.h" + +UCLASS(Config=Game, DefaultConfig, meta=(DisplayName="Vertical Partition")) +class VERTICALPARTITION_API UVerticalPartitionDeveloperSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + UVerticalPartitionDeveloperSettings(); + + virtual FName GetCategoryName() const override { return FName(TEXT("Plugins")); } + + /** Defaults applied to a newly placed AVerticalPartitionVolume. */ + UPROPERTY(Config, EditAnywhere, Category="Defaults") + FVerticalPartitionSettings DefaultSettings; + + /** Master debug switch mirrored by the vp.Debug console variable. */ + UPROPERTY(Config, EditAnywhere, Category="Debug") + bool bDebugEnabledByDefault = false; + + /** Frame time (ms) above which a streaming update is logged as a hitch. */ + UPROPERTY(Config, EditAnywhere, Category="Debug", meta=(ClampMin="1.0", Units="ms")) + float HitchThresholdMS = 8.0f; + + static const UVerticalPartitionDeveloperSettings* Get(); +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionStatics.h b/Source/VerticalPartition/Public/VerticalPartitionStatics.h new file mode 100644 index 0000000..6ead76d --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionStatics.h @@ -0,0 +1,71 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// Pure cell math shared by the editor builder and the runtime streamer. No state. +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionStatics.generated.h" + +/** Context the desired-state policy needs beyond the raw cell index. */ +struct FVerticalStreamingContext +{ + int32 PlayerCellZ = 0; + int32 PlayerCellX = 0; + int32 PlayerCellY = 0; + float PlayerVelocityZ = 0.f; // cm/s, +up + float PlayerWorldZ = 0.f; // cm, exact (for hysteresis at boundaries) + float CameraPitchDeg = 0.f; // +looking up + bool bUseXY = false; +}; + +UCLASS() +class VERTICALPARTITION_API UVerticalPartitionStatics : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** Z index of a world Z (cm) relative to the volume floor. */ + UFUNCTION(BlueprintPure, Category="VerticalPartition") + static int32 GetCellZFromWorldZ(float WorldZ, float VolumeMinZ, float CellHeight); + + UFUNCTION(BlueprintPure, Category="VerticalPartition") + static int32 GetCellXFromWorldX(float WorldX, float VolumeMinX, float XYCellSize); + UFUNCTION(BlueprintPure, Category="VerticalPartition") + static int32 GetCellYFromWorldY(float WorldY, float VolumeMinY, float XYCellSize); + + /** Cell id for a single point (actor origin). */ + static FVerticalCellId GetCellIdFromPoint(const FVector& Point, const FBox& VolumeBounds, const FVerticalPartitionSettings& S); + + /** Cell id for a bounds box. Uses the dominant-overlap cell (center of the + * clamped intersection) so a box straddling a boundary lands deterministically. + * bOutCrossesMultiple reports whether the box spans more than one cell. */ + static FVerticalCellId GetCellIdFromBounds(const FBox& ActorBounds, const FBox& VolumeBounds, const FVerticalPartitionSettings& S, bool& bOutCrossesMultiple); + + /** World-space bounds of a cell, clamped to the volume when requested. */ + static FBox GetCellBounds(const FVerticalCellId& Cell, const FBox& VolumeBounds, const FVerticalPartitionSettings& S); + + /** Number of Z cells the volume spans (>=1). */ + static int32 GetNumZCells(const FBox& VolumeBounds, const FVerticalPartitionSettings& S); + + /** THE policy. Maps a cell relative to the player to its desired representation, + * honouring full/preload/HLOD/unload bands, hysteresis, velocity prediction, + * fall-risk-below, camera direction and the above-player priority bias. + * CurrentState is the cell's live state and is what makes hysteresis sticky. */ + static EVerticalCellDesiredState GetDesiredStateForCell( + const FVerticalCellId& Cell, + const FVerticalStreamingContext& Ctx, + const FVerticalPartitionSettings& S, + EVerticalCellState CurrentState = EVerticalCellState::Unloaded); + + /** True when a cell sits in the preload band (load full data, keep it hidden). */ + static bool ShouldPreloadFull(const FVerticalCellId& Cell, const FVerticalStreamingContext& Ctx, const FVerticalPartitionSettings& S); + + /** Human-readable colour for a runtime state (debug draw / overlay). */ + UFUNCTION(BlueprintPure, Category="VerticalPartition") + static FLinearColor GetStateColor(EVerticalCellState State); + + UFUNCTION(BlueprintPure, Category="VerticalPartition") + static FString WarningTypeToString(EVerticalWarningType Type); +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionTypes.h b/Source/VerticalPartition/Public/VerticalPartitionTypes.h new file mode 100644 index 0000000..064d83f --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionTypes.h @@ -0,0 +1,486 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// Shared data contract for the whole plugin: cell identity, states, the tunable +// settings block, descriptors, and build/runtime report structs. +// +// This is a BOUNDED VERTICAL ROUTE partition system for a hand-crafted spiral +// climbing map. It is NOT an infinite open-world (World Partition) system and it +// does NOT use Level Instances as its core architecture. +#pragma once + +#include "CoreMinimal.h" +#include "VerticalPartitionTypes.generated.h" + +// --------------------------------------------------------------------------- +// Cell identity +// --------------------------------------------------------------------------- + +/** Integer grid coordinate of a cell. Z is the primary (vertical) axis; X/Y are + * only used when bUseXYSubCells is enabled. */ +USTRUCT(BlueprintType) +struct FVerticalCellId +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + int32 X = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + int32 Y = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + int32 Z = 0; + + FVerticalCellId() = default; + FVerticalCellId(int32 InX, int32 InY, int32 InZ) : X(InX), Y(InY), Z(InZ) {} + + bool operator==(const FVerticalCellId& Other) const + { + return X == Other.X && Y == Other.Y && Z == Other.Z; + } + bool operator!=(const FVerticalCellId& Other) const { return !(*this == Other); } + + FString ToString() const { return FString::Printf(TEXT("X%d_Y%d_Z%d"), X, Y, Z); } + + /** Stable, sortable label used for generated package names ("Cell_Z0010" / "Cell_X0_Y0_Z0010"). */ + FString ToLabel(bool bWithXY) const + { + return bWithXY + ? FString::Printf(TEXT("Cell_X%d_Y%d_Z%04d"), X, Y, Z) + : FString::Printf(TEXT("Cell_Z%04d"), Z); + } + + friend uint32 GetTypeHash(const FVerticalCellId& C) + { + return HashCombine(HashCombine(::GetTypeHash(C.X), ::GetTypeHash(C.Y)), ::GetTypeHash(C.Z)); + } +}; + +// --------------------------------------------------------------------------- +// State machine enums +// --------------------------------------------------------------------------- + +/** Live runtime state of a cell inside the streaming state machine. */ +UENUM(BlueprintType) +enum class EVerticalCellState : uint8 +{ + Unloaded, + LoadingHLOD, + HLOD, + PreloadingFull, + LoadingFull, + Full, + Unloading, + Error +}; + +/** The representation the streamer *wants* a cell to be in this update. */ +UENUM(BlueprintType) +enum class EVerticalCellDesiredState : uint8 +{ + Unloaded, + HLOD, + Full +}; + +/** How Build/Convert mutates the source level. */ +UENUM(BlueprintType) +enum class EVerticalConversionMode : uint8 +{ + /** Source level untouched. Cells stream by toggling the existing in-level actors + * (visibility/collision/tick) + showing proxies. Safe, no memory savings. */ + NonDestructive, + /** Actors are baked out into per-cell streaming sub-level packages and removed + * from the persistent level. True memory streaming. A backup is taken first. */ + DestructiveCommit +}; + +/** What to do with an actor that does not fit cleanly into a single cell. */ +UENUM(BlueprintType) +enum class ELargeActorPolicy : uint8 +{ + /** Leave it in the persistent (always-loaded) level. */ + KeepInPersistentLevel, + /** Assign it to the cell that owns the largest fraction of its bounds. */ + AssignToDominantCell, + /** Keep it only as part of HLOD proxies, never as a full streamed actor. */ + DuplicateAsHLODOnly, + /** Emit a warning — splitting a single actor across cells is not supported. */ + SplitNotSupportedWarning, + /** Drop it from partitioning entirely. */ + ExcludeFromPartition +}; + +/** How a cell's HLOD proxy is baked from its static geometry. */ +UENUM(BlueprintType) +enum class EVerticalHLODMethod : uint8 +{ + /** One merged static mesh, original materials preserved (exact silhouette, heavier). + * HISM/ISM instances are flattened into the single mesh. */ + MergedMesh, + /** Proxy LOD: simplified geometry + a single baked atlas material (cheapest, true HLOD). + * Requires the ProxyLODPlugin to be enabled; falls back to MergedMesh otherwise. */ + SimplifiedProxy +}; + +UENUM(BlueprintType) +enum class EVerticalIssueSeverity : uint8 +{ + Info, + Warning, + Error +}; + +/** Classifies an analysis/validation finding so UI can group and filter them. */ +UENUM(BlueprintType) +enum class EVerticalWarningType : uint8 +{ + None, + ActorCrossesMultipleCells, + ActorTooLarge, + ActorSimulatesPhysics, + ActorHasBlueprintTick, + ActorDynamicMobility, + ActorHasExternalReferences, + ActorAttachedAcrossCells, + ActorDependsOnOtherCell, + ActorNeedsManualReview, + ActorHasAudioLightNav, + ActorNotStreamSafe, + ActorHasUnsavedChanges, + CellTooManyActors, + CellTooManyDrawCalls, + HLODGenerationFailed, + PackageSaveFailed, + NoVolume, + DescriptorOutOfDate +}; + +/** Editor action dispatched from the volume's detail-panel buttons / console. */ +UENUM() +enum class EVerticalPartitionAction : uint8 +{ + Analyze, + Build, + RebuildChangedCells, + RebuildHLOD, + Validate, + PreviewChunks, + ClearGenerated, + Commit, + RestoreBackup, + DumpReport, + SpawnHLODPreview, + ClearPreview +}; + +// --------------------------------------------------------------------------- +// Tunable settings (one flat, category-grouped block reused everywhere) +// --------------------------------------------------------------------------- + +/** Everything tunable about segmentation, runtime streaming ranges, conversion + * and debug. Embedded in AVerticalPartitionVolume (per-volume) and snapshotted + * into UVerticalPartitionDescriptor at build time. Defaults are provided by + * UVerticalPartitionDeveloperSettings (project settings). */ +USTRUCT(BlueprintType) +struct FVerticalPartitionSettings +{ + GENERATED_BODY() + + // ===== Segmentation ===== + /** Height (cm) of one Z cell. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation", meta=(ClampMin="100.0", Units="cm")) + float CellHeight = 5000.f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + bool bUseXYSubCells = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation", meta=(EditCondition="bUseXYSubCells", ClampMin="100.0", Units="cm")) + float XYCellSize = 10000.f; + + /** Snap the generated grid to the volume bounds (no cells outside the volume). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + bool bClampToVolumeBounds = true; + + /** Only actors whose origin/bounds fall inside the volume are partitioned. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + bool bIncludeOnlyActorsInsideVolume = true; + + /** Assign by full actor bounds (true) or by actor origin point (false). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + bool bUseActorBoundsInsteadOfOrigin = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + ELargeActorPolicy LargeActorPolicy = ELargeActorPolicy::AssignToDominantCell; + + /** An actor is "large" when its Z extent exceeds this many cell heights. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation", meta=(ClampMin="1.0")) + float LargeActorCellSpan = 1.5f; + + /** SAFETY (recommended ON): only stream pure STATIC DECOR. Actors with lights, + * sky atmosphere / Ultra Dynamic Sky, sky-light, fog, post-process, audio, + * particles, reflection captures, decals, physics, replication, movable mobility, + * and Level Instances are kept in the persistent level and are NEVER hidden by + * streaming. Turn OFF only if every actor inside the volume is safe to hide. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + bool bStreamOnlyStaticDecor = true; + + /** Treat Level Instances / Packed Level Actors as streamable units: their static + * geometry (incl. HISM/ISM, flattened recursively) is baked into per-cell HLOD and + * their static child actors are hidden/shown by streaming. Lights / gameplay inside + * an LI stay persistent. Off => LIs are left fully persistent. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Segmentation") + bool bStreamLevelInstances = true; + + // ===== Runtime streaming ranges (in cells, measured from the player cell) ===== + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Full", meta=(ClampMin="0")) + int32 FullLoadCellsBelow = 1; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Full", meta=(ClampMin="0")) + int32 FullLoadCellsAbove = 1; + + /** Begin async-loading full data (kept resident, not yet shown) this many cells out. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Preload", meta=(ClampMin="0")) + int32 PreloadCellsBelow = 2; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Preload", meta=(ClampMin="0")) + int32 PreloadCellsAbove = 3; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|HLOD", meta=(ClampMin="0")) + int32 HLODLoadCellsBelow = 4; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|HLOD", meta=(ClampMin="0")) + int32 HLODLoadCellsAbove = 5; + + /** Beyond this many cells the cell is fully unloaded. Should be >= HLOD range. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Unload", meta=(ClampMin="0")) + int32 UnloadCellsBelow = 5; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Unload", meta=(ClampMin="0")) + int32 UnloadCellsAbove = 6; + + /** Z distance (cm) the player must move past a boundary before a downgrade fires. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Hysteresis", meta=(ClampMin="0.0", Units="cm")) + float HysteresisDistanceZ = 500.f; + + /** Extra cells kept at their current (higher) state to avoid thrash near edges. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Hysteresis", meta=(ClampMin="0")) + int32 HysteresisCells = 1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Budget", meta=(ClampMin="0.0", Units="s")) + float UpdateInterval = 0.2f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Budget", meta=(ClampMin="1")) + int32 MaxAsyncLoadsPerFrame = 1; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Budget", meta=(ClampMin="1")) + int32 MaxAsyncUnloadsPerFrame = 1; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Budget", meta=(ClampMin="0")) + int32 MaxVisibleHLODCells = 24; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Budget", meta=(ClampMin="0")) + int32 MaxFullCells = 8; + + // ===== Priority / prediction ===== + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Priority") + bool bPrioritizeAbovePlayer = true; + /** Keep cells below the player loaded longer (the player can always fall back down). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Priority") + bool bKeepBelowPlayerLoadedOnFallRisk = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Priority") + bool bUseCameraDirectionPriority = false; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Priority") + bool bUsePlayerVelocityPrediction = true; + /** How many seconds of velocity to extrapolate the player position for prediction. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Streaming|Priority", meta=(ClampMin="0.0", Units="s")) + float VelocityPredictionSeconds = 0.75f; + + // ===== Conversion ===== + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Conversion") + EVerticalConversionMode ConversionMode = EVerticalConversionMode::NonDestructive; + + /** Content root for generated cell maps / proxy meshes / descriptor. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Conversion") + FString GeneratedPackageRoot = TEXT("/Game/VPGenerated"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Conversion") + bool bCreateBackup = true; + + /** When set, only actors of these classes are considered for partitioning. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Conversion") + TArray> IncludeOnlyClasses; + + /** Actors of these classes are always excluded from partitioning. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Conversion") + TArray> ExcludeClasses; + + // ===== HLOD ===== + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HLOD") + bool bGenerateHLODProxies = true; + /** Only static-mesh actors below this draw-call-ish triangle budget are merged. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HLOD", meta=(ClampMin="0")) + int32 HLODMaxSourceActorsPerCell = 4096; + + /** MergedMesh (default, exact) or SimplifiedProxy (cheaper, needs ProxyLODPlugin). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HLOD") + EVerticalHLODMethod HLODMethod = EVerticalHLODMethod::MergedMesh; + + /** Target screen size (px) for SimplifiedProxy generation. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="HLOD", meta=(ClampMin="1", ClampMax="1200", EditCondition="HLODMethod==EVerticalHLODMethod::SimplifiedProxy")) + int32 ProxyScreenSize = 300; + + // ===== Debug ===== + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bDrawVolumeBounds = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bDrawZSlices = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bDrawXYCells = false; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bDrawCellIds = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bDrawActorCounts = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bDrawProblemActors = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Debug") + bool bShowRuntimeOverlay = true; +}; + +// --------------------------------------------------------------------------- +// Descriptor data (serialized in UVerticalPartitionDescriptor) +// --------------------------------------------------------------------------- + +/** One generated cell: bounds, the package that holds its full actors, its proxy + * asset, and the source actors that fed it. */ +USTRUCT(BlueprintType) +struct FVerticalCellDescriptor +{ + GENERATED_BODY() + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FVerticalCellId CellId; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FBox Bounds = FBox(ForceInit); + + /** Streaming sub-level package for the full representation (DestructiveCommit only). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FSoftObjectPath FullCellPackage; + + /** Offline-generated proxy static mesh shown while the cell is in HLOD range. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FSoftObjectPath HLODProxyAsset; + + /** Soft paths to the original actors assigned to this cell (NonDestructive backend). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + TArray SourceActors; + + /** Actors in *other* cells that this cell hard-references (cross-cell dependencies). */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + TArray Dependencies; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 ActorCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 StaticMeshCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 BlueprintActorCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 LightCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 DynamicActorCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + float EstimatedMemoryMB = 0.f; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + bool bHasWarnings = false; +}; + +/** A single analysis / validation finding. */ +USTRUCT(BlueprintType) +struct FVerticalPartitionWarning +{ + GENERATED_BODY() + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + EVerticalWarningType Type = EVerticalWarningType::None; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + EVerticalIssueSeverity Severity = EVerticalIssueSeverity::Warning; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FString Message; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FSoftObjectPath Actor; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FVerticalCellId Cell; +}; + +/** An error is a finding that blocks (or partially blocks) the build. Same shape. */ +USTRUCT(BlueprintType) +struct FVerticalPartitionError +{ + GENERATED_BODY() + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + EVerticalWarningType Type = EVerticalWarningType::None; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FString Message; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FSoftObjectPath Actor; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FVerticalCellId Cell; +}; + +/** Result of Analyze / Build, surfaced in the editor UI and dumpable to a report. */ +USTRUCT(BlueprintType) +struct FVerticalPartitionBuildReport +{ + GENERATED_BODY() + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 TotalActorsScanned = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 ActorsInsideVolume = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 ActorsExcluded = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 GeneratedCells = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 EmptyCells = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 WarningCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 ErrorCount = 0; + + /** The Z cell (and XY sub-cell) with the most actors, for the summary panel. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 LargestCellActorCount = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + FVerticalCellId LargestCell; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + TArray Warnings; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + TArray Errors; + + void Reset() { *this = FVerticalPartitionBuildReport(); } +}; + +/** Live runtime counters surfaced by the debug overlay / vp.DumpState. */ +USTRUCT(BlueprintType) +struct FVerticalRuntimeStats +{ + GENERATED_BODY() + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 FullCellsLoaded = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 HLODCellsVisible = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 CellsUnloaded = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 CellsLoading = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 ActiveAsyncLoads = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + float EstimatedMemoryMB = 0.f; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + float LastUpdateTimeMS = 0.f; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + float LastStreamingTimeMS = 0.f; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 HitchesOverThreshold = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + int32 PlayerCellZ = 0; +}; diff --git a/Source/VerticalPartition/Public/VerticalPartitionVolume.h b/Source/VerticalPartition/Public/VerticalPartitionVolume.h new file mode 100644 index 0000000..98b37c9 --- /dev/null +++ b/Source/VerticalPartition/Public/VerticalPartitionVolume.h @@ -0,0 +1,89 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +// +// The authoring volume. Drop one around the whole playable spiral, tune the +// settings, and press Build. Bounds come from a UBoxComponent so they are trivial +// to read at both editor and runtime (no BSP brush). +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionVolume.generated.h" + +class UBoxComponent; +class UVerticalPartitionDescriptor; +class AVerticalPartitionVolume; + +/** + * Decouples the runtime volume from the editor-only builder. The editor module + * installs Handler in StartupModule; the volume's CallInEditor buttons and the + * vp.* build console commands route through it. Lives in the runtime module so the + * volume can reference it, but is only ever populated in editor builds. + */ +struct VERTICALPARTITION_API FVerticalPartitionEditorBridge +{ +#if WITH_EDITOR + static TFunction Handler; + static void Execute(AVerticalPartitionVolume* Volume, EVerticalPartitionAction Action) + { + if (Handler) { Handler(Volume, Action); } + } +#endif +}; + +UCLASS(HideCategories=(Collision, Physics, Navigation, HLOD, Input, Replication)) +class VERTICALPARTITION_API AVerticalPartitionVolume : public AActor +{ + GENERATED_BODY() + +public: + AVerticalPartitionVolume(); + + /** Box that defines the bounded playable region to partition. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="VerticalPartition") + TObjectPtr Box; + + /** Per-volume settings (seeded from project defaults on placement). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition", meta=(ShowOnlyInnerProperties)) + FVerticalPartitionSettings Settings; + + /** Descriptor produced by Build; consumed by the runtime manager. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="VerticalPartition") + TSoftObjectPtr Descriptor; + + /** World-space bounds of the volume (box transform * extent). */ + UFUNCTION(BlueprintPure, Category="VerticalPartition") + FBox GetVolumeBounds() const; + + virtual void OnConstruction(const FTransform& Transform) override; + +#if WITH_EDITOR + // ---- Detail-panel buttons (mirror the editor tool window) ---- + UFUNCTION(CallInEditor, Category="Vertical Partition|1 - Analyze") + void AnalyzeCurrentLevel(); + UFUNCTION(CallInEditor, Category="Vertical Partition|2 - Build") + void BuildVerticalPartition(); + UFUNCTION(CallInEditor, Category="Vertical Partition|2 - Build") + void RebuildChangedCells(); + UFUNCTION(CallInEditor, Category="Vertical Partition|2 - Build") + void RebuildHLOD(); + UFUNCTION(CallInEditor, Category="Vertical Partition|3 - Validate") + void ValidateCells(); + UFUNCTION(CallInEditor, Category="Vertical Partition|3 - Validate") + void PreviewChunks(); + UFUNCTION(CallInEditor, Category="Vertical Partition|3 - Validate") + void SpawnHLODPreview(); + UFUNCTION(CallInEditor, Category="Vertical Partition|3 - Validate") + void ClearPreview(); + UFUNCTION(CallInEditor, Category="Vertical Partition|4 - Commit") + void CommitConversion(); + UFUNCTION(CallInEditor, Category="Vertical Partition|4 - Commit") + void RestoreFromBackup(); + UFUNCTION(CallInEditor, Category="Vertical Partition|5 - Maintenance") + void ClearGeneratedData(); + UFUNCTION(CallInEditor, Category="Vertical Partition|5 - Maintenance") + void DumpReport(); + + virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override; +#endif +}; diff --git a/Source/VerticalPartition/VerticalPartition.Build.cs b/Source/VerticalPartition/VerticalPartition.Build.cs new file mode 100644 index 0000000..f92e923 --- /dev/null +++ b/Source/VerticalPartition/VerticalPartition.Build.cs @@ -0,0 +1,30 @@ +// Copyright IHY. Vertical Partition Streaming plugin. +using UnrealBuildTool; + +public class VerticalPartition : ModuleRules +{ + public VerticalPartition(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + "DeveloperSettings", + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "RenderCore", + }); + + // Editor-only helpers compiled into the runtime module are guarded by WITH_EDITOR; + // the heavy editor build logic lives in the VerticalPartitionEditor module. + if (Target.bBuildEditor) + { + PrivateDependencyModuleNames.Add("UnrealEd"); + } + } +} diff --git a/Source/VerticalPartitionEditor/Private/VerticalPartitionBuilder.cpp b/Source/VerticalPartitionEditor/Private/VerticalPartitionBuilder.cpp new file mode 100644 index 0000000..8bf4caf --- /dev/null +++ b/Source/VerticalPartitionEditor/Private/VerticalPartitionBuilder.cpp @@ -0,0 +1,1005 @@ +// 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 Assignments; + GatherActors(Volume, Bounds, Assignments, Report); + + TMap 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& 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 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()) { return false; } + if (A->FindComponentByClass()) { return false; } + if (A->FindComponentByClass()) { return false; } + if (A->FindComponentByClass()) { return false; } + if (A->FindComponentByClass()) { return false; } + if (A->FindComponentByClass()) { 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 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& 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(Unit); + if (LI && World) + { + if (ULevelInstanceSubsystem* Sub = World->GetSubsystem()) + { + 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& OutAssignments, FVerticalPartitionBuildReport& Report) +{ + UWorld* World = Volume->GetWorld(); + const FVerticalPartitionSettings& S = Volume->Settings; + + auto IsExcludedClass = [&S](AActor* A) -> bool + { + for (const TSoftClassPtr& 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& 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() : nullptr; + + for (TActorIterator It(World); It; ++It) + { + AActor* A = *It; + if (!A || A == Volume || A->IsA() || A->IsA()) + { + 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(A) != nullptr); + const bool bIsPackedLI = A->IsA(APackedLevelActor::StaticClass()); + if (bIsLevelInstance && !bIsPackedLI && !S.bStreamLevelInstances) + { + Asg.bExcluded = true; + ++Report.ActorsExcluded; + OutAssignments.Add(Asg); + continue; + } + + TArray 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(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 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 Lights; + Actor->GetComponents(Lights); + bLight = Lights.Num() > 0; + + TArray 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(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 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 It(World); It; ++It) { bHasManager = true; break; } + if (!bHasManager) + { + if (AVerticalPartitionManager* Mgr = World->SpawnActor()) + { + 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& Assignments, UVerticalPartitionDescriptor* Descriptor, FVerticalPartitionBuildReport& Report) +{ + const FVerticalPartitionSettings& S = Volume->Settings; + const FString PackageRoot = S.GeneratedPackageRoot; + + // Group assignments by cell (skip excluded / persistent-kept). + TMap> CellActors; + for (const FVerticalActorAssignment& A : Assignments) + { + if (A.bExcluded || A.bLargeKeepPersistent) + { + continue; + } + TArray& Bucket = CellActors.FindOrAdd(A.Cell); + for (const TWeakObjectPtr& Leaf : A.LeafActors) + { + if (AActor* L = Leaf.Get()) + { + Bucket.Add(L); + } + } + } + + Descriptor->Cells.Reset(); + Descriptor->Cells.Reserve(CellActors.Num()); + + for (TPair>& 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 SMComps; + A->GetComponents(SMComps); + if (SMComps.Num() > 0) { ++CD.StaticMeshCount; CD.EstimatedMemoryMB += 0.25f * SMComps.Num(); } + + if (Cast(A->GetClass())) { ++CD.BlueprintActorCount; } + + TArray 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 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& 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 SMComps; + UWorld* World = nullptr; + for (AActor* A : CellActors) + { + if (!A) { continue; } + if (!World) { World = A->GetWorld(); } + TArray 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("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 Created; + FCreateProxyDelegate Delegate; + Delegate.BindLambda([&Created](const FGuid, TArray& 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(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 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 OutAssets; + FVector OutMergedLocation = FVector::ZeroVector; + Merge.MergeComponentsToStaticMesh(Prims, World, MergeSettings, nullptr, Package, AssetName, + OutAssets, OutMergedLocation, TNumericLimits::Max(), /*bSilent*/true); + + for (UObject* O : OutAssets) + { + if (UStaticMesh* M = Cast(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 CellActors; + for (const FSoftObjectPath& Path : CD.SourceActors) + { + if (AActor* A = Cast(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 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 //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 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(nullptr, *(PackagePath + TEXT(".") + AssetName))) + { + return OnDisk; + } + + UPackage* Package = CreatePackage(*PackagePath); + if (!Package) + { + return nullptr; + } + UVerticalPartitionDescriptor* Desc = NewObject(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& 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; +} diff --git a/Source/VerticalPartitionEditor/Private/VerticalPartitionCommandlet.cpp b/Source/VerticalPartitionEditor/Private/VerticalPartitionCommandlet.cpp new file mode 100644 index 0000000..b17222b --- /dev/null +++ b/Source/VerticalPartitionEditor/Private/VerticalPartitionCommandlet.cpp @@ -0,0 +1,62 @@ +// Copyright IHY. Vertical Partition Streaming plugin (editor module). +#include "VerticalPartitionCommandlet.h" +#include "VerticalPartitionBuilder.h" +#include "VerticalPartitionVolume.h" +#include "VerticalPartitionLog.h" +#include "EngineUtils.h" +#include "Engine/World.h" +#include "FileHelpers.h" +#include "Misc/PackageName.h" +#include "UObject/Package.h" + +UVerticalPartitionCommandlet::UVerticalPartitionCommandlet() +{ + IsClient = false; + IsServer = false; + IsEditor = true; + LogToConsole = true; +} + +int32 UVerticalPartitionCommandlet::Main(const FString& Params) +{ + FString MapPath; + if (!FParse::Value(*Params, TEXT("Map="), MapPath) || MapPath.IsEmpty()) + { + UE_LOG(LogVerticalPartition, Error, TEXT("[VP] Commandlet requires -Map=/Game/Maps/MyMap")); + return 1; + } + + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Commandlet loading map %s ..."), *MapPath); + UWorld* World = UEditorLoadingAndSavingUtils::LoadMap(MapPath); + if (!World) + { + UE_LOG(LogVerticalPartition, Error, TEXT("[VP] Failed to load map %s"), *MapPath); + return 1; + } + + AVerticalPartitionVolume* Volume = nullptr; + for (TActorIterator It(World); It; ++It) + { + Volume = *It; + break; + } + if (!Volume) + { + UE_LOG(LogVerticalPartition, Error, TEXT("[VP] No AVerticalPartitionVolume in %s"), *MapPath); + return 1; + } + + FVerticalPartitionBuildReport Report; + const bool bOk = UVerticalPartitionBuilder::Build(Volume, Report); + UVerticalPartitionBuilder::DumpReport(Report, FPackageName::GetShortName(MapPath)); + + if (bOk) + { + TArray ToSave; + ToSave.Add(World->GetOutermost()); + FEditorFileUtils::PromptForCheckoutAndSave(ToSave, /*bCheckDirty*/false, /*bPromptToSave*/false); + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Commandlet build OK: %d cells."), Report.GeneratedCells); + } + + return bOk ? 0 : 1; +} diff --git a/Source/VerticalPartitionEditor/Private/VerticalPartitionEditorModule.cpp b/Source/VerticalPartitionEditor/Private/VerticalPartitionEditorModule.cpp new file mode 100644 index 0000000..ab98f98 --- /dev/null +++ b/Source/VerticalPartitionEditor/Private/VerticalPartitionEditorModule.cpp @@ -0,0 +1,264 @@ +// Copyright IHY. Vertical Partition Streaming plugin (editor module). +// +// Installs the runtime->editor bridge so the volume's CallInEditor buttons and the +// vp.* build console commands reach UVerticalPartitionBuilder. Also implements the +// editor-only chunk preview. +#include "Modules/ModuleManager.h" +#include "VerticalPartitionVolume.h" +#include "VerticalPartitionBuilder.h" +#include "VerticalPartitionDescriptor.h" +#include "VerticalPartitionStatics.h" +#include "VerticalPartitionLog.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Components/StaticMeshComponent.h" +#include "DrawDebugHelpers.h" +#include "EngineUtils.h" +#include "Misc/PackageName.h" +#include "UObject/Package.h" + +static const FName VPPreviewTag(TEXT("VPHLODPreview")); + +#if WITH_EDITOR +#include "Editor.h" +#endif + +namespace +{ + void LogReport(const FVerticalPartitionBuildReport& R, const TCHAR* Title) + { + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] %s: scanned=%d inside=%d excluded=%d cells=%d empty=%d warn=%d err=%d largest=%s(%d)"), + Title, R.TotalActorsScanned, R.ActorsInsideVolume, R.ActorsExcluded, R.GeneratedCells, R.EmptyCells, + R.WarningCount, R.ErrorCount, *R.LargestCell.ToString(), R.LargestCellActorCount); + + if (GEngine) + { + GEngine->AddOnScreenDebugMessage(-1, 8.f, FColor::Cyan, + FString::Printf(TEXT("VP %s: %d cells, %d warnings, %d errors"), Title, R.GeneratedCells, R.WarningCount, R.ErrorCount)); + } + for (const FVerticalPartitionWarning& W : R.Warnings) + { + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] WARN [%s] %s"), *UVerticalPartitionStatics::WarningTypeToString(W.Type), *W.Message); + } + for (const FVerticalPartitionError& E : R.Errors) + { + UE_LOG(LogVerticalPartition, Error, TEXT("[VP] ERR [%s] %s"), *UVerticalPartitionStatics::WarningTypeToString(E.Type), *E.Message); + } + } + + // Persistent coloured cell boxes. GREEN = cell has a baked HLOD proxy, ORANGE = cell + // has streamed actors but no proxy (it will just hide when far), GREY = empty Z slice. + void PreviewChunks(AVerticalPartitionVolume* Volume) + { +#if WITH_EDITOR + if (!Volume || !GEditor) { return; } + UWorld* World = GEditor->GetEditorWorldContext().World(); + if (!World) { return; } + + const FVerticalPartitionSettings& S = Volume->Settings; + const FBox Bounds = Volume->GetVolumeBounds(); + UVerticalPartitionDescriptor* Desc = Volume->Descriptor.LoadSynchronous(); + + FlushPersistentDebugLines(World); + DrawDebugBox(World, Bounds.GetCenter(), Bounds.GetExtent(), FColor::Cyan, true, -1.f, 0, 30.f); + + const int32 NumZ = UVerticalPartitionStatics::GetNumZCells(Bounds, S); + int32 CellsWithHLOD = 0; + for (int32 Z = 0; Z < NumZ; ++Z) + { + const FVerticalCellId Id(0, 0, Z); + const FBox CB = UVerticalPartitionStatics::GetCellBounds(Id, Bounds, S); + + int32 Actors = 0; + bool bHasCell = false; + bool bHasHLOD = false; + if (Desc) + { + for (const FVerticalCellDescriptor& CD : Desc->Cells) + { + if (CD.CellId.Z == Z) + { + bHasCell = true; + Actors += CD.ActorCount; + if (CD.HLODProxyAsset.IsValid()) { bHasHLOD = true; } + } + } + } + if (bHasHLOD) { ++CellsWithHLOD; } + + const FColor Color = !bHasCell ? FColor(90, 90, 90) + : (bHasHLOD ? FColor(40, 200, 80) : FColor(235, 140, 30)); + DrawDebugBox(World, CB.GetCenter(), CB.GetExtent(), Color, true, -1.f, 0, 12.f); + + const FString Label = bHasCell + ? FString::Printf(TEXT("Z%d %s actors:%d"), Z, bHasHLOD ? TEXT("[HLOD]") : TEXT("[no-HLOD]"), Actors) + : FString::Printf(TEXT("Z%d (empty/persistent)"), Z); + DrawDebugString(World, CB.GetCenter(), Label, nullptr, Color, -1.f, true); + } + + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Preview: %d Z cells (%s). GREEN=HLOD proxy, ORANGE=streamed/no-proxy, GREY=empty. %d cells have a proxy. Use 'Clear Preview' to remove."), + NumZ, Desc ? TEXT("from descriptor") : TEXT("no descriptor yet — Build first for HLOD colours"), CellsWithHLOD); +#endif + } + + int32 ClearPreviewActors(UWorld* World) + { + int32 Removed = 0; +#if WITH_EDITOR + if (!World) { return 0; } + for (TActorIterator It(World); It; ++It) + { + if (It->ActorHasTag(VPPreviewTag)) + { + It->Destroy(); + ++Removed; + } + } +#endif + return Removed; + } + + void ClearPreview(AVerticalPartitionVolume* Volume) + { +#if WITH_EDITOR + if (!GEditor) { return; } + UWorld* World = GEditor->GetEditorWorldContext().World(); + if (!World) { return; } + FlushPersistentDebugLines(World); + const int32 Removed = ClearPreviewActors(World); + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Preview cleared (%d HLOD preview actors removed)."), Removed); +#endif + } + + // Spawn the baked proxy meshes as temporary editor actors so the HLOD geometry is + // directly visible in the viewport. They sit exactly over the source (pivot-at-zero), + // so hide the real decor to compare. Tagged + transient; removed by Clear Preview. + void SpawnHLODPreview(AVerticalPartitionVolume* Volume) + { +#if WITH_EDITOR + if (!Volume || !GEditor) { return; } + UWorld* World = GEditor->GetEditorWorldContext().World(); + if (!World) { return; } + + UVerticalPartitionDescriptor* Desc = Volume->Descriptor.LoadSynchronous(); + if (!Desc) + { + UE_LOG(LogVerticalPartition, Warning, TEXT("[VP] SpawnHLODPreview: no descriptor — run Build first.")); + return; + } + + ClearPreviewActors(World); + + int32 Spawned = 0; + for (const FVerticalCellDescriptor& CD : Desc->Cells) + { + if (!CD.HLODProxyAsset.IsValid()) { continue; } + UStaticMesh* Mesh = Cast(CD.HLODProxyAsset.TryLoad()); + if (!Mesh) { continue; } + + FActorSpawnParameters Params; + Params.ObjectFlags |= RF_Transient; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + AStaticMeshActor* Actor = World->SpawnActor(FVector::ZeroVector, FRotator::ZeroRotator, Params); + if (!Actor) { continue; } + + Actor->Tags.Add(VPPreviewTag); + Actor->SetActorLabel(FString::Printf(TEXT("VP_HLODPreview_Z%d"), CD.CellId.Z)); + if (UStaticMeshComponent* Comp = Actor->GetStaticMeshComponent()) + { + Comp->SetMobility(EComponentMobility::Movable); + Comp->SetStaticMesh(Mesh); + Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision); + } + ++Spawned; + } + + UE_LOG(LogVerticalPartition, Log, TEXT("[VP] Spawned %d HLOD proxy preview actors (tag '%s'). Hide your static decor to compare; 'Clear Preview' removes them."), + Spawned, *VPPreviewTag.ToString()); + if (GEngine) + { + GEngine->AddOnScreenDebugMessage(-1, 8.f, FColor::Green, FString::Printf(TEXT("VP: %d HLOD proxy preview actors spawned"), Spawned)); + } +#endif + } + + void HandleAction(AVerticalPartitionVolume* Volume, EVerticalPartitionAction Action) + { + if (!Volume) { return; } + FVerticalPartitionBuildReport Report; + switch (Action) + { + case EVerticalPartitionAction::Analyze: + Report = UVerticalPartitionBuilder::AnalyzeCurrentLevel(Volume); + LogReport(Report, TEXT("Analyze")); + break; + case EVerticalPartitionAction::Build: + UVerticalPartitionBuilder::Build(Volume, Report); + LogReport(Report, TEXT("Build")); + break; + case EVerticalPartitionAction::RebuildChangedCells: + UVerticalPartitionBuilder::RebuildChangedCells(Volume, Report); + LogReport(Report, TEXT("Rebuild")); + break; + case EVerticalPartitionAction::RebuildHLOD: + UVerticalPartitionBuilder::RebuildHLOD(Volume, Report); + LogReport(Report, TEXT("RebuildHLOD")); + break; + case EVerticalPartitionAction::Validate: + Report = UVerticalPartitionBuilder::ValidateCells(Volume); + LogReport(Report, TEXT("Validate")); + break; + case EVerticalPartitionAction::PreviewChunks: + PreviewChunks(Volume); + break; + case EVerticalPartitionAction::SpawnHLODPreview: + SpawnHLODPreview(Volume); + break; + case EVerticalPartitionAction::ClearPreview: + ClearPreview(Volume); + break; + case EVerticalPartitionAction::ClearGenerated: + UVerticalPartitionBuilder::ClearGenerated(Volume); + break; + case EVerticalPartitionAction::Commit: + UVerticalPartitionBuilder::CommitConversion(Volume, Report); + LogReport(Report, TEXT("Commit")); + break; + case EVerticalPartitionAction::RestoreBackup: + UVerticalPartitionBuilder::RestoreFromBackup(Volume->GetWorld()); + break; + case EVerticalPartitionAction::DumpReport: + if (UVerticalPartitionDescriptor* Desc = Volume->Descriptor.LoadSynchronous()) + { + const FString Map = Volume->GetWorld() ? FPackageName::GetShortName(Volume->GetWorld()->GetOutermost()->GetName()) : TEXT("Map"); + UVerticalPartitionBuilder::DumpReport(Desc->LastReport, Map); + } + break; + } + } +} + +class FVerticalPartitionEditorModule : public IModuleInterface +{ +public: + virtual void StartupModule() override + { +#if WITH_EDITOR + FVerticalPartitionEditorBridge::Handler = [](AVerticalPartitionVolume* Volume, EVerticalPartitionAction Action) + { + HandleAction(Volume, Action); + }; +#endif + } + + virtual void ShutdownModule() override + { +#if WITH_EDITOR + FVerticalPartitionEditorBridge::Handler = nullptr; +#endif + } +}; + +IMPLEMENT_MODULE(FVerticalPartitionEditorModule, VerticalPartitionEditor); diff --git a/Source/VerticalPartitionEditor/Public/VerticalPartitionBuilder.h b/Source/VerticalPartitionEditor/Public/VerticalPartitionBuilder.h new file mode 100644 index 0000000..94235f3 --- /dev/null +++ b/Source/VerticalPartitionEditor/Public/VerticalPartitionBuilder.h @@ -0,0 +1,89 @@ +// Copyright IHY. Vertical Partition Streaming plugin (editor module). +// +// Editor-only conversion pipeline: scan -> validate -> assign -> generate +// descriptor -> generate HLOD proxies -> (optionally) bake streaming sub-levels. +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "VerticalPartitionTypes.h" +#include "VerticalPartitionBuilder.generated.h" + +class AVerticalPartitionVolume; +class UVerticalPartitionDescriptor; +class UWorld; +class AActor; + +/** One actor's analysed placement, shared between Analyze and Build. */ +struct FVerticalActorAssignment +{ + TWeakObjectPtr Actor; + /** The renderable static-decor leaf actors this unit streams. For a normal actor this + * is just [Actor]; for a Level Instance it is its (recursively flattened) static child + * actors; for a Packed Level Actor it is [Actor] (its baked HISM live on it). */ + TArray> LeafActors; + FVerticalCellId Cell; + FBox Bounds = FBox(ForceInit); + bool bCrossesMultiple = false; + bool bExcluded = false; + bool bLargeKeepPersistent = false; +}; + +UCLASS() +class UVerticalPartitionBuilder : public UObject +{ + GENERATED_BODY() + +public: + /** Scan the level, classify every actor, return a report without writing anything. */ + static FVerticalPartitionBuildReport AnalyzeCurrentLevel(AVerticalPartitionVolume* Volume); + + /** Full build: analyze -> create/refresh descriptor -> generate HLOD proxies -> + * (DestructiveCommit only) bake streaming sub-levels + remove source actors. + * Returns the report; writes the descriptor asset. */ + static bool Build(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport); + + /** Re-run only cells whose source content hash changed since the last build. */ + static bool RebuildChangedCells(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport); + + /** (Re)generate HLOD proxy meshes for all cells. */ + static bool RebuildHLOD(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport); + + /** Re-run analysis against the existing descriptor and flag drift / problems. */ + static FVerticalPartitionBuildReport ValidateCells(AVerticalPartitionVolume* Volume); + + /** Promote a NonDestructive descriptor to baked streaming sub-levels. */ + static bool CommitConversion(AVerticalPartitionVolume* Volume, FVerticalPartitionBuildReport& OutReport); + + static bool CreateBackup(UWorld* World, FString& OutBackupPath); + static bool RestoreFromBackup(UWorld* World); + + /** Delete generated descriptor / cell maps / proxies under the output root. */ + static void ClearGenerated(AVerticalPartitionVolume* Volume); + + /** Write the report to Saved/VerticalPartition/_Report.txt and log it. */ + static FString DumpReport(const FVerticalPartitionBuildReport& Report, const FString& MapName); + +private: + // ---- pipeline steps ---- + static void GatherActors(AVerticalPartitionVolume* Volume, const FBox& VolumeBounds, + TArray& OutAssignments, FVerticalPartitionBuildReport& Report); + + static void ClassifyActorWarnings(const FVerticalActorAssignment& A, const FVerticalPartitionSettings& S, + FVerticalPartitionBuildReport& Report); + + static UVerticalPartitionDescriptor* CreateOrLoadDescriptor(AVerticalPartitionVolume* Volume); + + static void BuildCells(AVerticalPartitionVolume* Volume, const FBox& VolumeBounds, + const TArray& Assignments, + UVerticalPartitionDescriptor* Descriptor, FVerticalPartitionBuildReport& Report); + + /** Generate one proxy mesh for a cell from its static-mesh actors. Returns the + * asset path or an empty path (with a warning) on failure / unavailable API. */ + static FSoftObjectPath GenerateHLODProxyForCell(const FVerticalCellId& Cell, + const TArray& CellActors, const FVerticalPartitionSettings& S, + const FString& PackageRoot, FVerticalPartitionBuildReport& Report); + + static int64 ComputeContentHash(const TArray& Assignments); + static bool SaveAsset(UObject* Asset); +}; diff --git a/Source/VerticalPartitionEditor/Public/VerticalPartitionCommandlet.h b/Source/VerticalPartitionEditor/Public/VerticalPartitionCommandlet.h new file mode 100644 index 0000000..3651cfb --- /dev/null +++ b/Source/VerticalPartitionEditor/Public/VerticalPartitionCommandlet.h @@ -0,0 +1,20 @@ +// Copyright IHY. Vertical Partition Streaming plugin (editor module). +// +// Offline build entry point: +// UnrealEditor-Cmd.exe Project.uproject -run=VerticalPartition -Map=/Game/Maps/MyMap +#pragma once + +#include "CoreMinimal.h" +#include "Commandlets/Commandlet.h" +#include "VerticalPartitionCommandlet.generated.h" + +UCLASS() +class UVerticalPartitionCommandlet : public UCommandlet +{ + GENERATED_BODY() + +public: + UVerticalPartitionCommandlet(); + + virtual int32 Main(const FString& Params) override; +}; diff --git a/Source/VerticalPartitionEditor/VerticalPartitionEditor.Build.cs b/Source/VerticalPartitionEditor/VerticalPartitionEditor.Build.cs new file mode 100644 index 0000000..929b1f4 --- /dev/null +++ b/Source/VerticalPartitionEditor/VerticalPartitionEditor.Build.cs @@ -0,0 +1,37 @@ +// Copyright IHY. Vertical Partition Streaming plugin (editor module). +using UnrealBuildTool; + +public class VerticalPartitionEditor : ModuleRules +{ + public VerticalPartitionEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + "VerticalPartition", + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "UnrealEd", + "Slate", + "SlateCore", + "InputCore", + "EditorSubsystem", + "ToolMenus", + "Projects", + "AssetRegistry", + "AssetTools", + "MeshMergeUtilities", + "MeshDescription", + "StaticMeshDescription", + "MaterialUtilities", + "MeshReductionInterface", + "LevelEditor", + }); + } +} diff --git a/USAGE_RU.md b/USAGE_RU.md new file mode 100644 index 0000000..0a5602d --- /dev/null +++ b/USAGE_RU.md @@ -0,0 +1,109 @@ +# Vertical Partition — практическая инструкция + +> Пошаговое «что нажимать». Архитектура и детали — в [README.md](README.md). + +Плагин берёт **готовую** вертикальную карту (Only Up / Only Climb), режет её на +**Z-ячейки**, печёт офлайн **HLOD-прокси** и в рантайме грузит ближние ячейки целиком, +дальние показывает как прокси, совсем дальние выгружает. + +--- + +## 0. Один раз: включить плагин +Плагин `EnabledByDefault` — подхватывается сам. Если нет: `Edit → Plugins → Vertical +Partition → Enabled`, перезапустить редактор. После C++-правок нужна полная пересборка +(новые модули в редактор «на лету» не подгружаются). + +--- + +## 1. Поставить том вокруг трассы +1. В уровне: **Place Actors → ищи `Vertical Partition Volume`** → перетащи на сцену. +2. Масштабируй **Box** так, чтобы он охватил **всю** играбельную спираль (низ → верх). + Бокс рисуется голубым; коллизии у него нет. + +## 2. Настроить (в деталях тома, секция `Vertical Partition → Settings`) +Минимум, что нужно тронуть: +- **Cell Height** — высота одной ячейки (см). Ориентир: 1–2 «этажа» трассы. Например `5000`. +- **Full/HLOD/Unload ranges** (категория `Streaming`) — сколько ячеек вокруг игрока держать: + - `Full Load Cells Below/Above` — целиком (обычно 1/1). + - `HLOD Load Cells Below/Above` — прокси (например 4/5). + - `Unload Cells Below/Above` — дальше выгрузка. +- Остальное можно оставить по умолчанию. + +> **Важно про LI:** если трасса собрана из **Level Instance** — они должны быть +> **ЗАГРУЖЕНЫ** в редакторе (не свёрнуты) перед сборкой. Незагруженный LI пропустится +> с варнингом «load it and rebuild». + +## 3. Анализ (без записи) +Кнопка **`1 - Analyze → Analyze Current Level`**. +Смотри лог (Output Log, фильтр `LogVerticalPartition`): сколько акторов внутри тома, +сколько ячеек, сколько ушло в persistent, варнинги. Ничего не меняется на диске. + +## 4. Сборка +Кнопка **`2 - Build → Build Vertical Partition`**. +Что произойдёт: +- бэкап карты в `Saved/VerticalPartition/Backups/`; +- генерится дескриптор `/Game/VPGenerated/<Карта>/VPD_<Карта>`; +- пекутся HLOD-прокси в `…/Proxies/`; +- в уровень добавляется актор-менеджер (`Vertical Partition Manager`); +- том получает ссылку на дескриптор. + +## 5. Проверить HLOD во вьюпорте (без запуска игры) +- **`3 - Validate → Preview Chunks`** — цветные боксы ячеек: + - 🟢 **зелёный** = у ячейки есть HLOD-прокси; + - 🟠 **оранжевый** = стримится, но прокси нет (просто прячется вдали); + - ⚪ **серый** = пусто / persistent. +- **`3 - Validate → Spawn HLOD Preview`** — спавнит сами прокси-меши поверх оригинала. + Спрячь реальный декор (или посмотри издалека), чтобы оценить силуэт. +- **`3 - Validate → Clear Preview`** — убрать боксы и превью-акторы. + +## 6. Запустить и проверить стриминг +**Play**. Включи отладку в консоли (`~`): +``` +vp.Debug 1 +vp.DebugDraw 1 ; цветные боксы ячеек в 3D +vp.DebugOverlay 1 ; статистика на экране +``` +Цвета: 🟢 Full · 🔵 HLOD · 🟡 грузится · 🔴 ошибка · ⚪ выгружено · 🟣 forced. +Полезные команды: `vp.DumpCells`, `vp.TeleportToCell 12`, `vp.ForceCellHLOD 8`, +`vp.SetFullRange 2 2`, `vp.ProfileStreaming`. + +--- + +## Что стримится, а что НЕТ (запомни) +Стримится (прячется/заменяется прокси) **только чистый статик-декор**. +**Никогда не трогаются** (всегда persistent, всегда видны): +- свет, **Ultra Dynamic Sky**, Sky Atmosphere, SkyLight, туман, post-process; +- аудио, частицы/Niagara, reflection capture, декали; +- физика, реплицируемые, Movable-акторы; +- свет/геймплей **внутри** Level Instance. + +→ Поэтому небо и рабочие акторы **не пропадают**. Управляется флагом +`Settings → bStreamOnlyStaticDecor` (вкл по умолчанию — не выключай без причины). + +--- + +## Если что-то «пропало» / нужно откатить +NonDestructive-режим **ничего не удаляет с диска** — прячет только в игре (PIE). +- Полный сброс: удали актор `Vertical Partition Manager` из уровня + папку + `/Game/VPGenerated/`, затем `Build` заново. Или кнопка **`5 - Maintenance → Clear Generated Data`**. +- Восстановить карту из бэкапа: **`4 - Commit → Restore From Backup`**. + +--- + +## Частые проблемы +| Симптом | Причина / решение | +|---|---| +| Небо погасло / акторы исчезли в Play | Старый дескриптор до фикса. **Build заново** (теперь свет/небо/LI остаются persistent). | +| Level Instance не попал в чанки | LI был свёрнут. Загрузи его в редакторе → `Build`. | +| Ячейки все оранжевые (нет HLOD) | В ячейке нет статик-геометрии, либо `bGenerateHLODProxies` выключен. | +| `SimplifiedProxy` ничего не печёт | Нужен включённый **ProxyLODPlugin**. Без него авто-откат на `MergedMesh` (это ок). | +| Прокси смещён | Не должно — пекутся с пивотом в (0,0,0). Если видишь смещение — пересобери HLOD. | +| Хочу перепечь только прокси | **`2 - Build → Rebuild HLOD`**. | + +--- + +## Офлайн-сборка (CI / без открытия редактора) +``` +UnrealEditor-Cmd.exe IHY.uproject -run=VerticalPartition -Map=/Game/Maps/MyClimbMap +``` +Отчёт пишется в `Saved/VerticalPartition/<Карта>_Report.txt`. diff --git a/VerticalPartition.uplugin b/VerticalPartition.uplugin new file mode 100644 index 0000000..b321c42 --- /dev/null +++ b/VerticalPartition.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "0.1.0", + "FriendlyName": "Vertical Partition Streaming", + "Description": "Bounded vertical route partition / streaming system for hand-crafted spiral climbing maps (Only Up / Only Climb style). NOT an infinite open-world partition. Converts an existing level into Z-segmented streaming cells with offline HLOD proxies, a vertical-aware runtime streamer, and World-Partition-style debug visualization.", + "Category": "World", + "CreatedBy": "IHY", + "CanContainContent": false, + "IsBetaVersion": true, + "Installed": false, + "Modules": [ + { + "Name": "VerticalPartition", + "Type": "Runtime", + "LoadingPhase": "Default" + }, + { + "Name": "VerticalPartitionEditor", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +}