From a95b29968050e47318bcbf22dfd4e6d959357a17 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 1 Jul 2026 18:26:45 +0300 Subject: [PATCH] Mesh Optimizer: sibling StaticMesh duplicate remover (UE 5.7) Editor plugin that detects geometrically-identical sibling StaticMeshes across a level, rebases each placement onto one canonical mesh with a corrected transform (W' = D * W, verified by exact vertex matching), and can collapse groups into HISM. Native Slate tool panel + BlueprintCallable UOptimizerSubsystem. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 11 + Optimizer.uplugin | 21 + README.md | 153 +++++ .../OptimizerEditor/OptimizerEditor.Build.cs | 39 ++ .../Private/OptimizerEditorModule.cpp | 79 +++ .../Private/OptimizerGeometry.cpp | 292 +++++++++ .../Private/OptimizerGeometry.h | 53 ++ .../Private/OptimizerMatcher.cpp | 347 +++++++++++ .../Private/OptimizerMatcher.h | 35 ++ .../Private/OptimizerReconciler.cpp | 40 ++ .../Private/OptimizerReconciler.h | 27 + .../Private/OptimizerSubsystem.cpp | 565 ++++++++++++++++++ .../Private/SOptimizerPanel.cpp | 211 +++++++ .../OptimizerEditor/Private/SOptimizerPanel.h | 40 ++ .../Public/OptimizerEditorModule.h | 26 + .../Public/OptimizerSubsystem.h | 76 +++ .../OptimizerEditor/Public/OptimizerTypes.h | 157 +++++ 17 files changed, 2172 insertions(+) create mode 100644 .gitignore create mode 100644 Optimizer.uplugin create mode 100644 README.md create mode 100644 Source/OptimizerEditor/OptimizerEditor.Build.cs create mode 100644 Source/OptimizerEditor/Private/OptimizerEditorModule.cpp create mode 100644 Source/OptimizerEditor/Private/OptimizerGeometry.cpp create mode 100644 Source/OptimizerEditor/Private/OptimizerGeometry.h create mode 100644 Source/OptimizerEditor/Private/OptimizerMatcher.cpp create mode 100644 Source/OptimizerEditor/Private/OptimizerMatcher.h create mode 100644 Source/OptimizerEditor/Private/OptimizerReconciler.cpp create mode 100644 Source/OptimizerEditor/Private/OptimizerReconciler.h create mode 100644 Source/OptimizerEditor/Private/OptimizerSubsystem.cpp create mode 100644 Source/OptimizerEditor/Private/SOptimizerPanel.cpp create mode 100644 Source/OptimizerEditor/Private/SOptimizerPanel.h create mode 100644 Source/OptimizerEditor/Public/OptimizerEditorModule.h create mode 100644 Source/OptimizerEditor/Public/OptimizerSubsystem.h create mode 100644 Source/OptimizerEditor/Public/OptimizerTypes.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1038f0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Unreal Engine build artifacts +Binaries/ +Intermediate/ +DerivedDataCache/ +Saved/ + +# IDE / OS +.vs/ +*.pdb +*.VC.db +.DS_Store diff --git a/Optimizer.uplugin b/Optimizer.uplugin new file mode 100644 index 0000000..faef8f7 --- /dev/null +++ b/Optimizer.uplugin @@ -0,0 +1,21 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "0.1.0", + "FriendlyName": "Mesh Optimizer", + "Description": "Editor tool: detect geometrically-identical sibling StaticMeshes across level actors, reassign every user to one canonical mesh with a pivot/rotation-corrected transform, so the set can be converted to HISM.", + "Category": "Editor", + "CreatedBy": "IHY", + "EnabledByDefault": true, + "CanContainContent": false, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "OptimizerEditor", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ce5040 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Mesh Optimizer — Sibling Duplicate Remover + +Editor-only Unreal Engine plugin that finds **geometrically identical StaticMesh assets** used across a +level, rebases every placement onto a single **canonical** mesh with a corrected transform, and +optionally collapses the group into a **Hierarchical Instanced Static Mesh (HISM)**. + +> UE 5.7 · Editor module `OptimizerEditor` · native Slate tool panel. + +--- + +## The problem it solves + +Bought kits and **Kitbash3D**-style packs import the *same* geometry as many separate assets: +`SM_Fence_01`, `SM_Fence_02`, … are literal twins — identical vertices, sometimes with a different +**baked pivot or rotation**. That defeats instancing: the engine sees N different meshes, so it can't +batch them, and you can't make one HISM out of them. + +Mesh Optimizer detects those "sibling" meshes, picks one to keep, and re-points every actor that used a +sibling to the survivor — **fixing each actor's transform** so nothing moves on screen — leaving a set +that is ready to become a single HISM. + +--- + +## What it does + +1. **Scan** (dry run — mutates nothing): walk the level's StaticMesh placements, fingerprint every + unique mesh, group the geometric siblings, and report each group with a confidence figure + (`maxDeviation`). +2. **Unify**: reassign every sibling placement to its group's canonical mesh and apply the corrected + world transform. Undoable (`Ctrl+Z`). +3. **Build HISM**: create one `HierarchicalInstancedStaticMeshComponent` per group from the corrected + transforms, optionally destroying the originals. Undoable. + +Every mutation runs inside a single editor transaction. + +--- + +## How it works + +``` +extract geometry (LOD0 FMeshDescription) + → fingerprint (welded vertex/tri counts, surface area, |volume|, + covariance eigenvalues, radial histogram — all rotation-invariant) + → bucket by (welded vertex count, triangle count) + → recover the rigid delta between candidates + (identity fast-path, then the 24/48 signed-permutation axis rotations) + → verify by order-independent nearest-neighbour vertex match (spatial hash) + → union-find into groups, pick a canonical + → plan the corrected transform per placement +``` + +**The corrective transform.** With `D` = the recovered *canonical-local → sibling-local* delta and +`W` = the sibling placement's current world transform, the canonical mesh must be placed at + +``` +W' = D * W (UE FTransform multiply order; matrix W'_mat = D_mat * W_mat) +``` + +built via `FMatrix` and reconstructed into an `FTransform`. Because a non-uniform actor scale combined +with a rotational `D` can produce **shear** (which `FTransform` cannot represent), each result is +checked at the bounding-box corners and any placement that would shear is **skipped and reported** +rather than silently corrupted. + +Every candidate match is confirmed by an **exact vertex-set verification** before it is ever applied, so +a bad transform is rejected, not shipped. + +--- + +## Installation + +1. Copy this folder into your project's `Plugins/` directory (so it lives at `Plugins/Optimizer/`). +2. Add it to your `.uproject` (editor-only): + ```json + { "Name": "Optimizer", "Enabled": true, "TargetAllowList": [ "Editor" ] } + ``` +3. Regenerate project files and build the editor target (a new module needs a full build — Live Coding + cannot register a brand-new module). + +Requires **Unreal Engine 5.7** and an editor build (`Editor Win64 Development`). + +--- + +## Usage + +Open the panel from **Tools → Optimizer → Mesh Optimizer**. + +| Control | Effect | +|---|---| +| **Scan** | Dry-run analysis of the current level; fills the report. Changes nothing. | +| **Unify** | Reassign sibling placements to the canonical mesh + corrected transform. | +| **Build HISM** | Collapse each group into one HISM actor. | +| ☐ Mirrored | Also treat negative-scale (mirrored) variants as siblings. | +| ☐ Scaled | Also treat uniformly-scaled copies as siblings. | +| ☐ Merge materials | Group siblings even if their material sets differ (the canonical's materials win). | +| ☐ Destroy originals | When building HISM, delete the original actors/components. | + +Recommended flow: **Scan → review the report** (check `maxDev` and the flags) **→ Unify** (or **Build +HISM**). Everything is undoable with `Ctrl+Z`. + +--- + +## Detection modes + +- **Exact + rigid (default):** identical geometry up to a rigid transform (rotation and/or a baked + pivot offset). Covers almost all Kitbash cases. +- **+ Mirrored:** negative-scale reflections (kept as a separate canonical group, since HISM winding + differs). +- **+ Scaled:** uniform-scale copies (the instance carries the scale). + +Advanced tolerances (weld epsilon, accept tolerance, scalar relative tolerance, degenerate-eigenvalue +epsilon) are exposed on the scan settings for tuning against specific content. + +--- + +## Safety & limitations + +- **World Partition:** only *loaded* cells are visible to the scan — coverage is partial and the report + says so. +- **Shear:** non-uniform actor scale combined with a rotational delta is unrepresentable by + `FTransform`; those placements are skipped and counted, never corrupted. +- **ISM/HISM sources:** existing instanced components are out of scope in this version (only plain + `StaticMeshActor`s / components are processed). +- **Materials:** by default siblings with different material sets are kept in separate groups so each + HISM keeps one material set. + +--- + +## Architecture + +``` +Source/OptimizerEditor/ + OptimizerEditor.Build.cs + Public/ + OptimizerEditorModule.h – module + Tools menu / nomad tab + OptimizerSubsystem.h – UEditorSubsystem: ScanLevel / ApplyUnify / BuildHISM + OptimizerTypes.h – settings + result structs/enums + Private/ + OptimizerEditorModule.cpp + OptimizerGeometry.{h,cpp} – geometry extraction + fingerprint + 3x3 Jacobi eigensolver + OptimizerMatcher.{h,cpp} – bucketing, transform recovery, spatial-hash verification + OptimizerReconciler.{h,cpp}– W' = D * W + shear detection + OptimizerSubsystem.cpp – enumeration, planning, transacted mutation, HISM build + SOptimizerPanel.{h,cpp} – native Slate tool panel +``` + +The heavy logic is UI-agnostic C++ in `UOptimizerSubsystem` (`BlueprintCallable`); the Slate panel is a +thin driver. + +--- + +## License + +Internal tool. © IHY / ExbyteLabs. diff --git a/Source/OptimizerEditor/OptimizerEditor.Build.cs b/Source/OptimizerEditor/OptimizerEditor.Build.cs new file mode 100644 index 0000000..1c37331 --- /dev/null +++ b/Source/OptimizerEditor/OptimizerEditor.Build.cs @@ -0,0 +1,39 @@ +// Copyright IHY. + +using UnrealBuildTool; + +public class OptimizerEditor : ModuleRules +{ + public OptimizerEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", // UStaticMesh, UStaticMeshComponent, AActor, FTransform + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + // --- editor core --- + "UnrealEd", // GEditor, FScopedTransaction, UEditorActorSubsystem, editor world context + "EditorSubsystem", // UEditorSubsystem (base of UOptimizerSubsystem) + "Slate", // notifications / future Slate bits + "SlateCore", + "InputCore", + "ToolMenus", // UToolMenus — Tools menu entry + "Projects", // IPluginManager + "UMG", // UWidgetBlueprint chain pulled in by the EUW headers + "UMGEditor", // WidgetBlueprint.h include path (transitive via EditorUtilityWidgetBlueprint.h) + "Blutility", // UEditorUtilitySubsystem / UEditorUtilityWidgetBlueprint (open the EUW panel) + + // --- mesh geometry & asset discovery (load-bearing for fingerprinting) --- + "MeshDescription", // FMeshDescription — LOD0 source vertex/poly data + "StaticMeshDescription", // FStaticMeshAttributes, UStaticMesh::GetMeshDescription + "AssetRegistry", // enumerate UStaticMesh assets when needed + "AssetTools", // asset metadata helpers + }); + } +} diff --git a/Source/OptimizerEditor/Private/OptimizerEditorModule.cpp b/Source/OptimizerEditor/Private/OptimizerEditorModule.cpp new file mode 100644 index 0000000..8a27a54 --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerEditorModule.cpp @@ -0,0 +1,79 @@ +// Copyright IHY. +#include "OptimizerEditorModule.h" + +#include "Modules/ModuleManager.h" +#include "ToolMenus.h" +#include "ToolMenuSection.h" +#include "Framework/Commands/UIAction.h" +#include "Framework/Docking/TabManager.h" +#include "Framework/Application/SlateApplication.h" +#include "Widgets/Docking/SDockTab.h" +#include "Styling/AppStyle.h" +#include "SOptimizerPanel.h" + +#define LOCTEXT_NAMESPACE "OptimizerEditor" + +namespace { const FName OptimizerTabId(TEXT("MeshOptimizer")); } + +IMPLEMENT_MODULE(FOptimizerEditorModule, OptimizerEditor) + +void FOptimizerEditorModule::StartupModule() +{ + UToolMenus::RegisterStartupCallback( + FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FOptimizerEditorModule::RegisterMenus)); +} + +void FOptimizerEditorModule::ShutdownModule() +{ + if (bTabRegistered && FSlateApplication::IsInitialized()) + { + FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(OptimizerTabId); + bTabRegistered = false; + } + if (UToolMenus::IsToolMenuUIEnabled()) + { + UToolMenus::UnRegisterStartupCallback(this); + UToolMenus::UnregisterOwner(this); + } +} + +void FOptimizerEditorModule::RegisterMenus() +{ + FToolMenuOwnerScoped OwnerScoped(this); + + if (!bTabRegistered && FSlateApplication::IsInitialized()) + { + FGlobalTabmanager::Get()->RegisterNomadTabSpawner( + OptimizerTabId, + FOnSpawnTab::CreateRaw(this, &FOptimizerEditorModule::SpawnPanelTab)) + .SetDisplayName(LOCTEXT("OptimizerTabTitle", "Mesh Optimizer")) + .SetTooltipText(LOCTEXT("OptimizerTabTip", "Find sibling meshes and rebase them onto one canonical mesh (HISM-ready).")) + .SetMenuType(ETabSpawnerMenuType::Hidden); + bTabRegistered = true; + } + + if (UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools")) + { + FToolMenuSection& Section = ToolsMenu->FindOrAddSection("Optimizer", LOCTEXT("OptimizerSection", "Optimizer")); + Section.AddMenuEntry( + "OpenMeshOptimizer", + LOCTEXT("OpenOptimizer", "Mesh Optimizer"), + LOCTEXT("OpenOptimizerTip", "Find sibling meshes and rebase them onto one canonical mesh (HISM-ready)."), + FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Settings"), + FUIAction(FExecuteAction::CreateLambda([]() + { + FGlobalTabmanager::Get()->TryInvokeTab(OptimizerTabId); + }))); + } +} + +TSharedRef FOptimizerEditorModule::SpawnPanelTab(const FSpawnTabArgs& Args) +{ + return SNew(SDockTab) + .TabRole(ETabRole::NomadTab) + [ + SNew(SOptimizerPanel) + ]; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/OptimizerEditor/Private/OptimizerGeometry.cpp b/Source/OptimizerEditor/Private/OptimizerGeometry.cpp new file mode 100644 index 0000000..10f801c --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerGeometry.cpp @@ -0,0 +1,292 @@ +// Copyright IHY. +#include "OptimizerGeometry.h" + +#include "Engine/StaticMesh.h" +#include "MeshDescription.h" +#include "StaticMeshAttributes.h" +#include "StaticMeshResources.h" +#include "Rendering/PositionVertexBuffer.h" +#include "Materials/MaterialInterface.h" + +namespace +{ + // Read LOD0 source geometry into compact arrays. Returns false if no source model. + bool ReadFromMeshDescription(const UStaticMesh* Mesh, TArray& OutRaw, TArray& OutTris) + { + if (!Mesh->IsMeshDescriptionValid(0)) + { + return false; + } + const FMeshDescription* MD = Mesh->GetMeshDescription(0); + if (!MD || MD->Vertices().Num() == 0) + { + return false; + } + + FStaticMeshConstAttributes Attributes(*MD); + TVertexAttributesConstRef Positions = Attributes.GetVertexPositions(); + + OutRaw.Reset(MD->Vertices().Num()); + TMap Compact; + Compact.Reserve(MD->Vertices().Num()); + for (const FVertexID VertexID : MD->Vertices().GetElementIDs()) + { + Compact.Add(VertexID, OutRaw.Num()); + OutRaw.Add(FVector(Positions[VertexID])); + } + + OutTris.Reset(MD->Triangles().Num()); + for (const FTriangleID TriangleID : MD->Triangles().GetElementIDs()) + { + TArrayView Tri = MD->GetTriangleVertices(TriangleID); + if (Tri.Num() == 3) + { + OutTris.Add(FIntVector(Compact[Tri[0]], Compact[Tri[1]], Compact[Tri[2]])); + } + } + return OutRaw.Num() > 0; + } + + // Fallback: built render-data LOD0 (welded/optimized). Tag the result as render-derived. + bool ReadFromRenderData(const UStaticMesh* Mesh, TArray& OutRaw, TArray& OutTris) + { + const FStaticMeshRenderData* RD = Mesh->GetRenderData(); + if (!RD || RD->LODResources.Num() == 0) + { + return false; + } + const FStaticMeshLODResources& LOD = RD->LODResources[0]; + const FPositionVertexBuffer& Pos = LOD.VertexBuffers.PositionVertexBuffer; + const uint32 NumVerts = Pos.GetNumVertices(); + if (NumVerts == 0) + { + return false; + } + + OutRaw.Reset(NumVerts); + for (uint32 i = 0; i < NumVerts; ++i) + { + OutRaw.Add(FVector(Pos.VertexPosition(i))); + } + + TArray Indices; + LOD.IndexBuffer.GetCopy(Indices); + OutTris.Reset(Indices.Num() / 3); + for (int32 i = 0; i + 2 < Indices.Num(); i += 3) + { + OutTris.Add(FIntVector((int32)Indices[i], (int32)Indices[i + 1], (int32)Indices[i + 2])); + } + return true; + } +} + +namespace OptimizerGeometry +{ + void SymmetricEigen3x3(const double In[3][3], double OutValues[3], double OutVecs[3][3]) + { + double a[3][3]; + double v[3][3] = { {1,0,0}, {0,1,0}, {0,0,1} }; + for (int32 i = 0; i < 3; ++i) + { + for (int32 j = 0; j < 3; ++j) + { + a[i][j] = In[i][j]; + } + } + + // Cyclic Jacobi rotations on the three off-diagonal entries. + for (int32 Sweep = 0; Sweep < 64; ++Sweep) + { + const double Off = FMath::Abs(a[0][1]) + FMath::Abs(a[0][2]) + FMath::Abs(a[1][2]); + if (Off < 1e-18) + { + break; + } + static const int32 P[3] = { 0, 0, 1 }; + static const int32 Q[3] = { 1, 2, 2 }; + for (int32 k = 0; k < 3; ++k) + { + const int32 p = P[k]; + const int32 q = Q[k]; + if (FMath::Abs(a[p][q]) < 1e-300) + { + continue; + } + const double Theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]); + double t = (Theta >= 0.0 ? 1.0 : -1.0) / (FMath::Abs(Theta) + FMath::Sqrt(Theta * Theta + 1.0)); + const double c = 1.0 / FMath::Sqrt(t * t + 1.0); + const double s = t * c; + + // Rotate a: a = Jᵀ a J + const double app = a[p][p]; + const double aqq = a[q][q]; + const double apq = a[p][q]; + a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq; + a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq; + a[p][q] = 0.0; + a[q][p] = 0.0; + const int32 r = 3 - p - q; // the third index + const double arp = a[r][p]; + const double arq = a[r][q]; + a[r][p] = c * arp - s * arq; + a[p][r] = a[r][p]; + a[r][q] = s * arp + c * arq; + a[q][r] = a[r][q]; + + // Accumulate eigenvectors: v = v J + for (int32 i = 0; i < 3; ++i) + { + const double vip = v[i][p]; + const double viq = v[i][q]; + v[i][p] = c * vip - s * viq; + v[i][q] = s * vip + c * viq; + } + } + } + + int32 Order[3] = { 0, 1, 2 }; + const double Diag[3] = { a[0][0], a[1][1], a[2][2] }; + // Sort indices by eigenvalue DESC. + if (Diag[Order[0]] < Diag[Order[1]]) { Swap(Order[0], Order[1]); } + if (Diag[Order[0]] < Diag[Order[2]]) { Swap(Order[0], Order[2]); } + if (Diag[Order[1]] < Diag[Order[2]]) { Swap(Order[1], Order[2]); } + + for (int32 i = 0; i < 3; ++i) + { + OutValues[i] = Diag[Order[i]]; + OutVecs[0][i] = v[0][Order[i]]; + OutVecs[1][i] = v[1][Order[i]]; + OutVecs[2][i] = v[2][Order[i]]; + } + } + + bool ExtractGeom(const UStaticMesh* Mesh, float WeldEps, bool bWantPositions, FOptMeshGeom& Out) + { + Out = FOptMeshGeom(); + if (!Mesh) + { + return false; + } + + TArray Raw; + TArray Tris; + if (ReadFromMeshDescription(Mesh, Raw, Tris)) + { + Out.bRenderDerived = false; + } + else if (ReadFromRenderData(Mesh, Raw, Tris)) + { + Out.bRenderDerived = true; + } + else + { + return false; + } + + Out.RawVertexCount = Raw.Num(); + Out.TriangleCount = Tris.Num(); + + // Surface area + signed volume from the raw triangle soup (welding doesn't change these). + double Area = 0.0; + double Vol6 = 0.0; + for (const FIntVector& T : Tris) + { + const FVector& A = Raw[T.X]; + const FVector& B = Raw[T.Y]; + const FVector& C = Raw[T.Z]; + Area += 0.5 * FVector::CrossProduct(B - A, C - A).Size(); + Vol6 += FVector::DotProduct(A, FVector::CrossProduct(B, C)); + } + Out.SurfaceArea = Area; + Out.Volume = FMath::Abs(Vol6) / 6.0; + + // Weld positions onto a grid so seam-split duplicates collapse to one unique position. + const double InvEps = (WeldEps > UE_KINDA_SMALL_NUMBER) ? (1.0 / (double)WeldEps) : 1.0; + TMap Grid; + Grid.Reserve(Raw.Num()); + TArray Welded; + Welded.Reserve(Raw.Num()); + for (const FVector& P : Raw) + { + const FIntVector Key( + FMath::RoundToInt(P.X * InvEps), + FMath::RoundToInt(P.Y * InvEps), + FMath::RoundToInt(P.Z * InvEps)); + if (!Grid.Contains(Key)) + { + Grid.Add(Key, Welded.Num()); + Welded.Add(P); + } + } + Out.WeldedVertexCount = Welded.Num(); + if (Welded.Num() == 0) + { + return false; + } + + // Centroid (mean of welded positions) + bounds. + FVector Sum(0.0); + FBox Box(ForceInit); + for (const FVector& P : Welded) + { + Sum += P; + Box += P; + } + Out.Centroid = Sum / (double)Welded.Num(); + Out.LocalBounds = Box; + + // Covariance of centered welded cloud -> eigenvalues (rotation invariant). + double Cov[3][3] = { {0,0,0}, {0,0,0}, {0,0,0} }; + double MaxR = 0.0; + for (const FVector& P : Welded) + { + const FVector d = P - Out.Centroid; + Cov[0][0] += d.X * d.X; Cov[0][1] += d.X * d.Y; Cov[0][2] += d.X * d.Z; + Cov[1][1] += d.Y * d.Y; Cov[1][2] += d.Y * d.Z; + Cov[2][2] += d.Z * d.Z; + MaxR = FMath::Max(MaxR, d.Size()); + } + const double InvN = 1.0 / (double)Welded.Num(); + Cov[0][0] *= InvN; Cov[0][1] *= InvN; Cov[0][2] *= InvN; + Cov[1][1] *= InvN; Cov[1][2] *= InvN; Cov[2][2] *= InvN; + Cov[1][0] = Cov[0][1]; Cov[2][0] = Cov[0][2]; Cov[2][1] = Cov[1][2]; + double EVecs[3][3]; + SymmetricEigen3x3(Cov, Out.EigenValues, EVecs); + + // Radial histogram of |v - centroid|, normalized by the max radius then to sum 1. + if (MaxR > UE_KINDA_SMALL_NUMBER) + { + const double InvMax = (double)FOptMeshGeom::NumRadialBins / MaxR; + for (const FVector& P : Welded) + { + const double r = (P - Out.Centroid).Size(); + int32 Bin = (int32)FMath::FloorToDouble(r * InvMax); + Bin = FMath::Clamp(Bin, 0, FOptMeshGeom::NumRadialBins - 1); + Out.RadialHistogram[Bin] += 1.0; + } + for (int32 i = 0; i < FOptMeshGeom::NumRadialBins; ++i) + { + Out.RadialHistogram[i] *= InvN; + } + } + + // Material / section signature (kept separate from geometry grouping). + uint32 H = 0; + const TArray& Mats = Mesh->GetStaticMaterials(); + for (const FStaticMaterial& M : Mats) + { + const FString N = M.MaterialInterface ? M.MaterialInterface->GetPathName() : TEXT("None"); + H = HashCombine(H, GetTypeHash(N)); + } + Out.MaterialHash = H; + Out.SectionCount = Mats.Num(); + + if (bWantPositions) + { + Out.Positions = MoveTemp(Welded); + } + + Out.bValid = true; + return true; + } +} diff --git a/Source/OptimizerEditor/Private/OptimizerGeometry.h b/Source/OptimizerEditor/Private/OptimizerGeometry.h new file mode 100644 index 0000000..660c85a --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerGeometry.h @@ -0,0 +1,53 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" + +class UStaticMesh; + +/** + * Rotation/translation-invariant geometry fingerprint of a single UStaticMesh, plus the + * (lazily loaded) welded vertex cloud used for exact pairwise verification. + * + * Source is always LOD0 FMeshDescription (the imported authoring geometry) so the fingerprint + * is stable across LOD reduction / Nanite / build settings. Render data is a tagged fallback. + */ +struct FOptMeshGeom +{ + static constexpr int32 NumRadialBins = 16; + + bool bValid = false; + bool bRenderDerived = false; // true if extracted from render data (compare only against other render-derived) + + int32 RawVertexCount = 0; + int32 WeldedVertexCount = 0; // unique positions after welding -> the hard fingerprint gate + int32 TriangleCount = 0; + + double SurfaceArea = 0.0; // local space, rigid-invariant, scales as s^2 + double Volume = 0.0; // |signed tetrahedron sum|, scales as s^3 + FVector Centroid = FVector::ZeroVector; // mean of welded positions (local origin for cov/hist) + double EigenValues[3] = { 0.0, 0.0, 0.0 }; // covariance eigenvalues, sorted DESC (rotation-invariant) + double RadialHistogram[NumRadialBins] = { 0.0 }; // normalized hist of |v - centroid| + FBox LocalBounds = FBox(ForceInit); + + // Heavy payload, populated only when this mesh enters pairwise recovery (bWantPositions). + TArray Positions; // welded unique positions, local space + + // Material / section signature (NOT part of geometry grouping; used to partition for HISM). + uint32 MaterialHash = 0; + int32 SectionCount = 0; +}; + +namespace OptimizerGeometry +{ + /** + * Extract the fingerprint (and optionally the welded position cloud) for a static mesh. + * @param WeldEps position weld grid in cm. + * @param bWantPositions also fill Out.Positions (the expensive part) for verification. + * @return false if no usable geometry could be read. + */ + bool ExtractGeom(const UStaticMesh* Mesh, float WeldEps, bool bWantPositions, FOptMeshGeom& Out); + + /** Symmetric 3x3 Jacobi eigensolver. Eigenvalues returned sorted DESC; eigenvectors as columns of OutVecs. */ + void SymmetricEigen3x3(const double M[3][3], double OutValues[3], double OutVecs[3][3]); +} diff --git a/Source/OptimizerEditor/Private/OptimizerMatcher.cpp b/Source/OptimizerEditor/Private/OptimizerMatcher.cpp new file mode 100644 index 0000000..a27eeec --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerMatcher.cpp @@ -0,0 +1,347 @@ +// Copyright IHY. +#include "OptimizerMatcher.h" + +namespace +{ + // --- small helpers --------------------------------------------------------- + + bool RelClose(double A, double B, double RelTol) + { + const double Scale = FMath::Max3(FMath::Abs(A), FMath::Abs(B), 1.0); + return FMath::Abs(A - B) <= RelTol * Scale; + } + + struct FRot3 + { + double M[3][3] = { {1,0,0},{0,1,0},{0,0,1} }; + bool bImproper = false; + }; + + FVector ApplyRot(const double R[3][3], const FVector& V) + { + return FVector( + R[0][0] * V.X + R[0][1] * V.Y + R[0][2] * V.Z, + R[1][0] * V.X + R[1][1] * V.Y + R[1][2] * V.Z, + R[2][0] * V.X + R[2][1] * V.Y + R[2][2] * V.Z); + } + + // All 48 signed permutation rotations split into proper (det+1) and improper (det-1). + const TArray& AxisRotations() + { + static TArray Cached; + if (Cached.Num() == 0) + { + static const int32 Perm[6][3] = { {0,1,2},{0,2,1},{1,0,2},{1,2,0},{2,0,1},{2,1,0} }; + static const int32 Parity[6] = { 1, -1, -1, 1, 1, -1 }; + for (int32 p = 0; p < 6; ++p) + { + for (int32 sgn = 0; sgn < 8; ++sgn) + { + const int32 e0 = (sgn & 1) ? -1 : 1; + const int32 e1 = (sgn & 2) ? -1 : 1; + const int32 e2 = (sgn & 4) ? -1 : 1; + const int32 Det = Parity[p] * e0 * e1 * e2; + FRot3 R; + FMemory::Memzero(R.M, sizeof(R.M)); + R.M[0][Perm[p][0]] = e0; + R.M[1][Perm[p][1]] = e1; + R.M[2][Perm[p][2]] = e2; + R.bImproper = (Det < 0); + Cached.Add(R); + } + } + } + return Cached; + } + + // Uniform spatial hash over a point cloud for order-independent nearest-neighbour queries. + struct FPointGrid + { + double InvCell = 1.0; + const TArray* Pts = nullptr; + TMap> Cells; + + void Build(const TArray& InPts, double Cell) + { + Pts = &InPts; + InvCell = 1.0 / FMath::Max(Cell, 1e-6); + Cells.Reserve(InPts.Num()); + for (int32 i = 0; i < InPts.Num(); ++i) + { + Cells.FindOrAdd(KeyOf(InPts[i])).Add(i); + } + } + + FIntVector KeyOf(const FVector& P) const + { + return FIntVector( + FMath::FloorToInt(P.X * InvCell), + FMath::FloorToInt(P.Y * InvCell), + FMath::FloorToInt(P.Z * InvCell)); + } + + // Nearest point within MaxDist. Returns index or INDEX_NONE; OutDist set when found. + int32 Nearest(const FVector& Q, double MaxDist, double& OutDist) const + { + const FIntVector Base = KeyOf(Q); + double Best = MaxDist * MaxDist; + int32 BestIdx = INDEX_NONE; + // Search enough neighbour cells to cover MaxDist (cell size = 1/InvCell), not just +/-1, + // so reported deviations for over-tolerance vertices aren't truncated. + const int32 Rad = FMath::Max(1, FMath::CeilToInt(MaxDist * InvCell)); + for (int32 dz = -Rad; dz <= Rad; ++dz) + for (int32 dy = -Rad; dy <= Rad; ++dy) + for (int32 dx = -Rad; dx <= Rad; ++dx) + { + const TArray* Bucket = Cells.Find(Base + FIntVector(dx, dy, dz)); + if (!Bucket) + { + continue; + } + for (int32 Idx : *Bucket) + { + const double D2 = FVector::DistSquared((*Pts)[Idx], Q); + if (D2 < Best) + { + Best = D2; + BestIdx = Idx; + } + } + } + if (BestIdx != INDEX_NONE) + { + OutDist = FMath::Sqrt(Best); + } + return BestIdx; + } + }; + + // Transform canonical point p -> member space via q = s*R*(p - cA) + cB. + FVector Map(const double R[3][3], double s, const FVector& cA, const FVector& cB, const FVector& P) + { + return s * ApplyRot(R, P - cA) + cB; + } + + // Cheap score over a subsample: sum of NN distances, with a fixed penalty for misses (lower is better). + double ScoreCandidate(const TArray& A, const FPointGrid& BGrid, + const double R[3][3], double s, const FVector& cA, const FVector& cB, + double Tol, int32 Stride) + { + const double MissPenalty = Tol * 1000.0; + double Sum = 0.0; + for (int32 i = 0; i < A.Num(); i += Stride) + { + const FVector Q = Map(R, s, cA, cB, A[i]); + double Dist; + if (BGrid.Nearest(Q, Tol, Dist) != INDEX_NONE) + { + Sum += Dist; + } + else + { + Sum += MissPenalty; + } + } + return Sum; + } + + // Full verification of a winning candidate: per-vertex max/rms deviation + reverse coverage. + void VerifyCandidate(const TArray& A, const TArray& B, const FPointGrid& BGrid, + const double R[3][3], double s, const FVector& cA, const FVector& cB, + double Tol, double& OutMaxDev, double& OutRms, bool& OutCoverageOK) + { + double MaxDev = 0.0; + double SumSq = 0.0; + int32 Count = 0; + TBitArray<> Hit(false, B.Num()); + bool bAllWithin = true; + for (const FVector& P : A) + { + const FVector Q = Map(R, s, cA, cB, P); + double Dist; + const int32 Idx = BGrid.Nearest(Q, Tol * 4.0, Dist); // widen search so over-tol matches still report + if (Idx == INDEX_NONE || Dist > Tol) + { + bAllWithin = false; + MaxDev = FMath::Max(MaxDev, Idx == INDEX_NONE ? Tol * 4.0 : Dist); + } + else + { + Hit[Idx] = true; + MaxDev = FMath::Max(MaxDev, Dist); + } + SumSq += (Idx == INDEX_NONE) ? (Tol * 4.0) * (Tol * 4.0) : Dist * Dist; + ++Count; + } + OutMaxDev = MaxDev; + OutRms = (Count > 0) ? FMath::Sqrt(SumSq / Count) : 0.0; + OutCoverageOK = bAllWithin && (Hit.CountSetBits() >= B.Num()); + } +} + +namespace OptimizerMatcher +{ + bool FingerprintCompatible(const FOptMeshGeom& A, const FOptMeshGeom& B, const FOptimizerScanSettings& S) + { + if (!A.bValid || !B.bValid) + { + return false; + } + if (A.bRenderDerived != B.bRenderDerived) + { + return false; // never cross-compare source vs render-derived geometry + } + // Hard gates: welded vertex + triangle counts are exactly rotation/mirror invariant. + if (A.WeldedVertexCount != B.WeldedVertexCount || A.TriangleCount != B.TriangleCount) + { + return false; + } + if (!S.bMergeAcrossMaterials && A.MaterialHash != B.MaterialHash) + { + return false; + } + + const double RelTol = FMath::Max((double)S.ScalarRelTolerance, 1e-6); + + if (S.bAllowUniformScale) + { + // Require area/volume/eigenvalue ratios to agree on a single scale factor s. + if (A.SurfaceArea <= 0 || B.SurfaceArea <= 0) + { + return false; + } + const double sArea = FMath::Sqrt(B.SurfaceArea / A.SurfaceArea); + const double sVol = (A.Volume > 0 && B.Volume > 0) ? FMath::Pow(B.Volume / A.Volume, 1.0 / 3.0) : sArea; + const double sEig = (A.EigenValues[0] > 0 && B.EigenValues[0] > 0) ? FMath::Sqrt(B.EigenValues[0] / A.EigenValues[0]) : sArea; + if (!RelClose(sArea, sVol, 0.02) || !RelClose(sArea, sEig, 0.02)) + { + return false; + } + } + else + { + if (!RelClose(A.SurfaceArea, B.SurfaceArea, RelTol)) + { + return false; + } + if (A.Volume > 0 && B.Volume > 0 && !RelClose(A.Volume, B.Volume, RelTol)) + { + return false; + } + for (int32 i = 0; i < 3; ++i) + { + if (!RelClose(A.EigenValues[i], B.EigenValues[i], FMath::Max(RelTol, 1e-3))) + { + return false; + } + } + } + + // Radial histograms are normalized per-mesh (scale tolerant); compare by L1. + double L1 = 0.0; + for (int32 i = 0; i < FOptMeshGeom::NumRadialBins; ++i) + { + L1 += FMath::Abs(A.RadialHistogram[i] - B.RadialHistogram[i]); + } + return L1 <= 0.02; + } + + bool RecoverDelta(const FOptMeshGeom& A, const FOptMeshGeom& B, const FOptimizerScanSettings& S, FOptDelta& Out) + { + Out = FOptDelta(); + if (A.Positions.Num() == 0 || B.Positions.Num() == 0) + { + return false; + } + + const double Tol = FMath::Max((double)S.AcceptTolerance, 1e-5); + FPointGrid BGrid; + BGrid.Build(B.Positions, Tol); + + // Scoring stride keeps the 24/48 candidate scan cheap on dense meshes. + const int32 Stride = FMath::Max(1, A.Positions.Num() / 1500); + + auto BuildMatrix = [&](const double R[3][3], double s, const FVector& cA, const FVector& cB) -> FMatrix + { + // Linear part L = s*R (column form); UE row-vector matrix is L^T with translation row tt. + const FVector tt = cB - s * ApplyRot(R, cA); + FMatrix Mtx = FMatrix::Identity; + for (int32 i = 0; i < 3; ++i) + { + for (int32 j = 0; j < 3; ++j) + { + Mtx.M[i][j] = s * R[j][i]; + } + } + Mtx.M[3][0] = tt.X; Mtx.M[3][1] = tt.Y; Mtx.M[3][2] = tt.Z; + return Mtx; + }; + + // --- Exact-only mode: just test true identity (same orientation AND pivot) --- + if (S.Precision == EOptimizerMatchPrecision::ExactOnly) + { + static const double I3[3][3] = { {1,0,0},{0,1,0},{0,0,1} }; + double MaxDev, Rms; bool Cov; + VerifyCandidate(A.Positions, B.Positions, BGrid, I3, 1.0, FVector::ZeroVector, FVector::ZeroVector, Tol, MaxDev, Rms, Cov); + if (Cov) + { + Out.bValid = true; + Out.bIdentity = true; + Out.MaxDev = MaxDev; + Out.Rms = Rms; + Out.CanonToMember = FMatrix::Identity; + return true; + } + return false; + } + + // --- Rigid mode: identity-with-pivot fast path, then brute-force axis rotations --- + const double sEst = (S.bAllowUniformScale && A.SurfaceArea > 0) + ? FMath::Sqrt(B.SurfaceArea / A.SurfaceArea) : 1.0; + + struct FBest { double Score = TNumericLimits::Max(); FRot3 R; double s = 1.0; }; + FBest Best; + + // Candidate set. + TArray Candidates; + static const FRot3 Identity; // R = I, proper + Candidates.Add(&Identity); + for (const FRot3& R : AxisRotations()) + { + if (R.bImproper && !S.bAllowMirrored) + { + continue; + } + Candidates.Add(&R); + } + + for (const FRot3* R : Candidates) + { + const double Score = ScoreCandidate(A.Positions, BGrid, R->M, sEst, A.Centroid, B.Centroid, Tol, Stride); + if (Score < Best.Score) + { + Best.Score = Score; + Best.R = *R; + Best.s = sEst; + } + } + + // Full verify the winner. + double MaxDev, Rms; bool Cov; + VerifyCandidate(A.Positions, B.Positions, BGrid, Best.R.M, Best.s, A.Centroid, B.Centroid, Tol, MaxDev, Rms, Cov); + if (!Cov) + { + return false; + } + + Out.bValid = true; + Out.bMirrored = Best.R.bImproper; + Out.bScaled = !FMath::IsNearlyEqual(Best.s, 1.0, 1e-4); + Out.Scale = Best.s; + Out.MaxDev = MaxDev; + Out.Rms = Rms; + Out.CanonToMember = BuildMatrix(Best.R.M, Best.s, A.Centroid, B.Centroid); + Out.bIdentity = !Out.bMirrored && !Out.bScaled && Out.CanonToMember.Equals(FMatrix::Identity, 1e-4); + return true; + } +} diff --git a/Source/OptimizerEditor/Private/OptimizerMatcher.h b/Source/OptimizerEditor/Private/OptimizerMatcher.h new file mode 100644 index 0000000..008f295 --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerMatcher.h @@ -0,0 +1,35 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" +#include "OptimizerTypes.h" +#include "OptimizerGeometry.h" + +/** + * Recovered rigid (optionally mirrored/scaled) delta that maps CANONICAL-local geometry onto + * MEMBER(sibling)-local geometry. Stored as a UE row-vector matrix: v_member = v_canon * CanonToMember. + * This is exactly the D in the corrective formula W' = D * W. + */ +struct FOptDelta +{ + bool bValid = false; + bool bIdentity = false; // delta is (within tolerance) identity -> swap mesh, keep transform + bool bMirrored = false; // recovered rotation is improper (det < 0) -> negative-scale instance + bool bScaled = false; // uniform scale != 1 folded in + double Scale = 1.0; + double MaxDev = 0.0; // worst per-vertex deviation under the delta, cm + double Rms = 0.0; + FMatrix CanonToMember = FMatrix::Identity; +}; + +namespace OptimizerMatcher +{ + /** Cheap bucket test: are these two fingerprints compatible enough to attempt recovery? */ + bool FingerprintCompatible(const FOptMeshGeom& A, const FOptMeshGeom& B, const FOptimizerScanSettings& Settings); + + /** + * Recover the delta mapping canonical-local (A) onto member-local (B). Both A.Positions and + * B.Positions must be populated. Returns false if no candidate passes verification. + */ + bool RecoverDelta(const FOptMeshGeom& A, const FOptMeshGeom& B, const FOptimizerScanSettings& Settings, FOptDelta& Out); +} diff --git a/Source/OptimizerEditor/Private/OptimizerReconciler.cpp b/Source/OptimizerEditor/Private/OptimizerReconciler.cpp new file mode 100644 index 0000000..ef8af4b --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerReconciler.cpp @@ -0,0 +1,40 @@ +// Copyright IHY. +#include "OptimizerReconciler.h" + +namespace OptimizerReconciler +{ + bool ComputeCorrectedWorld( + const FMatrix& CanonToMember, + const FTransform& W, + const FBox& CanonLocalBounds, + double ShearTol, + FTransform& OutWprime, + double& OutMaxCornerDev) + { + // Exact corrected world matrix: apply D first (canon-local -> sibling-local), then W. + const FMatrix Exact = CanonToMember * W.ToMatrixWithScale(); + OutWprime.SetFromMatrix(Exact); + + // Compare the FTransform reconstruction against the exact matrix at the canonical bbox corners. + // Any gap means the exact matrix had shear that FTransform discarded -> not representable. + const FVector Mn = CanonLocalBounds.Min; + const FVector Mx = CanonLocalBounds.Max; + const FVector Corners[8] = + { + FVector(Mn.X, Mn.Y, Mn.Z), FVector(Mx.X, Mn.Y, Mn.Z), + FVector(Mn.X, Mx.Y, Mn.Z), FVector(Mx.X, Mx.Y, Mn.Z), + FVector(Mn.X, Mn.Y, Mx.Z), FVector(Mx.X, Mn.Y, Mx.Z), + FVector(Mn.X, Mx.Y, Mx.Z), FVector(Mx.X, Mx.Y, Mx.Z) + }; + + double MaxDev = 0.0; + for (const FVector& C : Corners) + { + const FVector ExactPt = Exact.TransformPosition(C); + const FVector ViaTransform = OutWprime.TransformPosition(C); + MaxDev = FMath::Max(MaxDev, FVector::Dist(ExactPt, ViaTransform)); + } + OutMaxCornerDev = MaxDev; + return MaxDev <= ShearTol; + } +} diff --git a/Source/OptimizerEditor/Private/OptimizerReconciler.h b/Source/OptimizerEditor/Private/OptimizerReconciler.h new file mode 100644 index 0000000..f329af1 --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerReconciler.h @@ -0,0 +1,27 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" + +namespace OptimizerReconciler +{ + /** + * Compute the corrected world transform for the canonical mesh so it lands exactly where the + * sibling currently sits. With D = CanonToMember (row-vector matrix) and W = sibling world + * transform: W'_matrix = D * W_matrix (the verified formula). + * + * FTransform cannot represent shear; if the actor's non-uniform scale combined with D's rotation + * produces shear, the reconstructed FTransform deviates from the exact matrix at the bbox corners. + * We measure that deviation and reject (return false) when it exceeds ShearTol — the caller then + * skips and reports the placement rather than silently corrupting it. + * + * @return false if the result shears (unrepresentable) — OutWprime is then not safe to apply. + */ + bool ComputeCorrectedWorld( + const FMatrix& CanonToMember, + const FTransform& W, + const FBox& CanonLocalBounds, + double ShearTol, + FTransform& OutWprime, + double& OutMaxCornerDev); +} diff --git a/Source/OptimizerEditor/Private/OptimizerSubsystem.cpp b/Source/OptimizerEditor/Private/OptimizerSubsystem.cpp new file mode 100644 index 0000000..cfbab92 --- /dev/null +++ b/Source/OptimizerEditor/Private/OptimizerSubsystem.cpp @@ -0,0 +1,565 @@ +// Copyright IHY. +#include "OptimizerSubsystem.h" + +#include "OptimizerGeometry.h" +#include "OptimizerMatcher.h" +#include "OptimizerReconciler.h" + +#include "Editor.h" +#include "Subsystems/EditorActorSubsystem.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Engine/World.h" +#include "GameFramework/Actor.h" +#include "Components/StaticMeshComponent.h" +#include "Components/InstancedStaticMeshComponent.h" +#include "Components/HierarchicalInstancedStaticMeshComponent.h" +#include "ScopedTransaction.h" + +DEFINE_LOG_CATEGORY_STATIC(LogOptimizer, Log, All); + +namespace +{ + UStaticMesh* ChooseCanonical( + const TArray& Members, + const TMap& Geoms, + const TMap& Counts, + EOptimizerCanonicalPolicy Policy) + { + UStaticMesh* Best = Members.Num() > 0 ? Members[0] : nullptr; + if (!Best) + { + return nullptr; + } + switch (Policy) + { + case EOptimizerCanonicalPolicy::LowestVertexCount: + for (UStaticMesh* M : Members) + { + const FOptMeshGeom* G = Geoms.Find(M); + const FOptMeshGeom* BG = Geoms.Find(Best); + if (G && BG && G->WeldedVertexCount < BG->WeldedVertexCount) + { + Best = M; + } + } + break; + case EOptimizerCanonicalPolicy::FirstAlphabetical: + for (UStaticMesh* M : Members) + { + if (M->GetName() < Best->GetName()) + { + Best = M; + } + } + break; + case EOptimizerCanonicalPolicy::MostInstances: + default: + for (UStaticMesh* M : Members) + { + const int32 C = Counts.FindRef(M); + const int32 BC = Counts.FindRef(Best); + if (C > BC) + { + Best = M; + } + } + break; + } + return Best; + } +} + +FOptimizerScanResult UOptimizerSubsystem::ScanLevel(const FOptimizerScanSettings& Settings) +{ + LastSettings = Settings; + Groups.Reset(); + + FOptimizerScanResult Result; + UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr; + if (!World) + { + Result.Summary = TEXT("No editor world."); + return Result; + } + Result.bWorldPartitionCoverageWarning = World->IsPartitionedWorld(); + + // --- 1. Gather non-instanced static-mesh placements --- + TArray Placements; + UEditorActorSubsystem* AS = GEditor->GetEditorSubsystem(); + TArray Actors; + if (AS) + { + Actors = (Settings.Scope == EOptimizerScanScope::SelectedActors) + ? AS->GetSelectedLevelActors() + : AS->GetAllLevelActors(); + } + Result.ActorsScanned = Actors.Num(); + + for (AActor* Actor : Actors) + { + if (!Actor) + { + continue; + } + TArray SMCs; + Actor->GetComponents(SMCs); + for (UStaticMeshComponent* SMC : SMCs) + { + // ISM/HISM sources are out of v1 scope (avoids per-instance removal complexity). + if (SMC->IsA()) + { + continue; + } + UStaticMesh* M = SMC->GetStaticMesh(); + if (!M) + { + continue; + } + FPlacement P; + P.Component = SMC; + P.Mesh = M; + P.World = SMC->GetComponentTransform(); + Placements.Add(MoveTemp(P)); + } + } + Result.ComponentsScanned = Placements.Num(); + + // --- 2. Fingerprint every unique mesh --- + TSet UniqueSet; + for (const FPlacement& P : Placements) + { + if (P.Mesh.IsValid()) + { + UniqueSet.Add(P.Mesh.Get()); + } + } + TArray Unique = UniqueSet.Array(); + Result.UniqueMeshes = Unique.Num(); + + TMap Geoms; + Geoms.Reserve(Unique.Num()); + for (UStaticMesh* M : Unique) + { + FOptMeshGeom G; + if (OptimizerGeometry::ExtractGeom(M, Settings.WeldEpsilon, /*bWantPositions*/false, G)) + { + Geoms.Add(M, MoveTemp(G)); + } + } + + auto EnsurePositions = [&](UStaticMesh* M) + { + FOptMeshGeom* G = Geoms.Find(M); + if (G && G->Positions.Num() == 0) + { + OptimizerGeometry::ExtractGeom(M, Settings.WeldEpsilon, /*bWantPositions*/true, *G); + } + }; + + TMap MeshInstanceCount; + for (const FPlacement& P : Placements) + { + if (P.Mesh.IsValid()) + { + MeshInstanceCount.FindOrAdd(P.Mesh.Get())++; + } + } + + // --- 3. Bucket by (welded vertex count, triangle count) --- + TMap, TArray> Buckets; + for (UStaticMesh* M : Unique) + { + const FOptMeshGeom* G = Geoms.Find(M); + if (G && G->bValid) + { + Buckets.FindOrAdd(TPair(G->WeldedVertexCount, G->TriangleCount)).Add(M); + } + } + + const double ShearTol = FMath::Max((double)Settings.AcceptTolerance, 0.01); + + // --- 4. Recover + group within each bucket --- + for (auto& BucketPair : Buckets) + { + TArray& Bucket = BucketPair.Value; + if (Bucket.Num() < 2) + { + continue; + } + for (UStaticMesh* M : Bucket) + { + EnsurePositions(M); + } + + const int32 K = Bucket.Num(); + TArray Parent; + Parent.SetNum(K); + for (int32 i = 0; i < K; ++i) + { + Parent[i] = i; + } + auto Find = [&Parent](int32 x) + { + while (Parent[x] != x) + { + Parent[x] = Parent[Parent[x]]; + x = Parent[x]; + } + return x; + }; + + for (int32 i = 0; i < K; ++i) + { + for (int32 j = i + 1; j < K; ++j) + { + const FOptMeshGeom& Gi = Geoms[Bucket[i]]; + const FOptMeshGeom& Gj = Geoms[Bucket[j]]; + if (!OptimizerMatcher::FingerprintCompatible(Gi, Gj, Settings)) + { + continue; + } + FOptDelta D; + if (OptimizerMatcher::RecoverDelta(Gi, Gj, Settings, D)) + { + Parent[Find(i)] = Find(j); + } + } + } + + TMap> Components; + for (int32 i = 0; i < K; ++i) + { + Components.FindOrAdd(Find(i)).Add(Bucket[i]); + } + + for (auto& CompPair : Components) + { + TArray& Members = CompPair.Value; + if (Members.Num() < 2) + { + continue; + } + + UStaticMesh* Canon = ChooseCanonical(Members, Geoms, MeshInstanceCount, Settings.CanonicalPolicy); + if (!Canon) + { + continue; + } + EnsurePositions(Canon); + const FOptMeshGeom& CanonGeom = Geoms[Canon]; + + // Recover canon->member delta for each member (direct re-verify, no transitivity trust). + TMap Deltas; + FGroup Group; + Group.Canonical = Canon; + for (UStaticMesh* Mem : Members) + { + if (Geoms[Mem].MaterialHash != CanonGeom.MaterialHash) + { + Group.bMaterialMismatch = true; + } + if (Mem == Canon) + { + FOptDelta Id; + Id.bValid = true; + Id.bIdentity = true; + Id.CanonToMember = FMatrix::Identity; + Deltas.Add(Mem, Id); + continue; + } + EnsurePositions(Mem); + FOptDelta D; + if (OptimizerMatcher::RecoverDelta(CanonGeom, Geoms[Mem], Settings, D)) + { + Group.bHasMirrored |= D.bMirrored; + Group.bHasScaled |= D.bScaled; + Group.MaxDeviation = FMath::Max(Group.MaxDeviation, (float)D.MaxDev); + Deltas.Add(Mem, D); + } + } + if (Deltas.Num() < 2) + { + continue; + } + for (const auto& DPair : Deltas) + { + Group.Members.Add(DPair.Key); + } + + // Plan each placement whose mesh is in this group. + for (const FPlacement& P : Placements) + { + UStaticMesh* PM = P.Mesh.Get(); + const FOptDelta* D = PM ? Deltas.Find(PM) : nullptr; + if (!D) + { + continue; + } + FPlacement Plan = P; + if (D->bIdentity) + { + Plan.PlannedWorld = P.World; + Plan.bNeedsTransformFix = false; + } + else + { + FTransform Wp; + double CornerDev = 0.0; + if (OptimizerReconciler::ComputeCorrectedWorld(D->CanonToMember, P.World, CanonGeom.LocalBounds, ShearTol, Wp, CornerDev)) + { + Plan.PlannedWorld = Wp; + Plan.bNeedsTransformFix = true; + } + else + { + Plan.bShearRejected = true; + Plan.PlannedWorld = P.World; + Group.ShearRejected++; + } + } + Group.Placements.Add(MoveTemp(Plan)); + } + + if (Group.Placements.Num() > 0) + { + Groups.Add(MoveTemp(Group)); + } + } + } + + // --- 5. Build the UI view --- + FOptimizerScanResult View = BuildResultView(); + View.ActorsScanned = Result.ActorsScanned; + View.ComponentsScanned = Result.ComponentsScanned; + View.UniqueMeshes = Result.UniqueMeshes; + View.bWorldPartitionCoverageWarning = Result.bWorldPartitionCoverageWarning; + + int32 TotalDupMeshes = 0; + for (const FOptimizerGroupView& GV : View.Groups) + { + TotalDupMeshes += FMath::Max(0, GV.MemberMeshNames.Num() - 1); + } + View.Summary = FString::Printf( + TEXT("%d sibling group(s): %d duplicate mesh asset(s) across %d placement(s)%s%s"), + View.Groups.Num(), TotalDupMeshes, View.InstancesCollapsible, + View.bWorldPartitionCoverageWarning ? TEXT(" | World Partition: only loaded cells scanned") : TEXT(""), + View.Groups.ContainsByPredicate([](const FOptimizerGroupView& G) { return G.ShearRejectedCount > 0; }) + ? TEXT(" | some placements skipped (shear)") : TEXT("")); + + UE_LOG(LogOptimizer, Log, TEXT("Optimizer scan: %s"), *View.Summary); + return View; +} + +FOptimizerScanResult UOptimizerSubsystem::BuildResultView() const +{ + FOptimizerScanResult View; + int32 Collapsible = 0; + for (int32 gi = 0; gi < Groups.Num(); ++gi) + { + const FGroup& G = Groups[gi]; + FOptimizerGroupView V; + V.GroupId = gi; + if (UStaticMesh* Canon = G.Canonical.Get()) + { + V.CanonicalMeshName = Canon->GetName(); + V.CanonicalMeshPath = Canon->GetPathName(); + } + for (const TWeakObjectPtr& M : G.Members) + { + if (UStaticMesh* SM = M.Get()) + { + V.MemberMeshNames.Add(SM->GetName()); + } + } + V.InstanceCount = G.Placements.Num(); + for (const FPlacement& P : G.Placements) + { + if (P.bNeedsTransformFix) + { + V.TransformFixCount++; + } + } + V.MaxDeviation = G.MaxDeviation; + V.bHasMirrored = G.bHasMirrored; + V.bHasScaled = G.bHasScaled; + V.bMaterialMismatch = G.bMaterialMismatch; + V.ShearRejectedCount = G.ShearRejected; + Collapsible += G.Placements.Num(); + View.Groups.Add(MoveTemp(V)); + } + View.InstancesCollapsible = Collapsible; + return View; +} + +int32 UOptimizerSubsystem::ApplyUnify() +{ + if (Groups.Num() == 0) + { + return 0; + } + const FScopedTransaction Transaction(NSLOCTEXT("Optimizer", "Unify", "Optimizer: unify sibling meshes")); + int32 Changed = 0; + for (FGroup& G : Groups) + { + UStaticMesh* Canon = G.Canonical.Get(); + if (!Canon) + { + continue; + } + for (FPlacement& P : G.Placements) + { + if (P.bShearRejected) + { + continue; + } + UStaticMeshComponent* C = P.Component.Get(); + if (!C) + { + continue; + } + if (P.Mesh.Get() == Canon && !P.bNeedsTransformFix) + { + continue; // already canonical, nothing to fix + } + C->Modify(); + if (AActor* Owner = C->GetOwner()) + { + Owner->Modify(); + } + C->SetStaticMesh(Canon); + C->SetWorldTransform(P.PlannedWorld, false, nullptr, ETeleportType::TeleportPhysics); + C->MarkRenderStateDirty(); + if (AActor* Owner = C->GetOwner()) + { + Owner->MarkPackageDirty(); + } + ++Changed; + } + } + if (GEditor) + { + GEditor->RedrawLevelEditingViewports(); + } + UE_LOG(LogOptimizer, Log, TEXT("Optimizer unify: %d placement(s) reassigned."), Changed); + return Changed; +} + +int32 UOptimizerSubsystem::BuildHISM(bool bDestroyOriginals) +{ + if (Groups.Num() == 0 || !GEditor) + { + return 0; + } + UWorld* World = GEditor->GetEditorWorldContext().World(); + if (!World) + { + return 0; + } + UEditorActorSubsystem* AS = GEditor->GetEditorSubsystem(); + const FScopedTransaction Transaction(NSLOCTEXT("Optimizer", "HISM", "Optimizer: build HISM")); + int32 Built = 0; + TSet Destroyed; + + for (FGroup& G : Groups) + { + UStaticMesh* Canon = G.Canonical.Get(); + if (!Canon) + { + continue; + } + TArray Instances; + for (const FPlacement& P : G.Placements) + { + if (!P.bShearRejected) + { + Instances.Add(P.PlannedWorld); + } + } + if (Instances.Num() == 0) + { + continue; + } + + FActorSpawnParameters Sp; + Sp.ObjectFlags |= RF_Transactional; + AActor* Holder = World->SpawnActor(AActor::StaticClass(), FTransform::Identity, Sp); + if (!Holder) + { + continue; + } + UHierarchicalInstancedStaticMeshComponent* HISM = + NewObject(Holder, NAME_None, RF_Transactional); + HISM->SetStaticMesh(Canon); + HISM->SetMobility(EComponentMobility::Static); + Holder->SetRootComponent(HISM); + Holder->AddInstanceComponent(HISM); + HISM->RegisterComponent(); + for (const FTransform& T : Instances) + { + HISM->AddInstance(T, /*bWorldSpace*/true); + } + Holder->SetActorLabel(FString::Printf(TEXT("HISM_%s"), *Canon->GetName())); + ++Built; + + if (bDestroyOriginals) + { + for (FPlacement& P : G.Placements) + { + if (P.bShearRejected) + { + continue; + } + UStaticMeshComponent* C = P.Component.Get(); + if (!C) + { + continue; + } + AActor* Owner = C->GetOwner(); + if (Owner && Owner->IsA()) + { + if (!Destroyed.Contains(Owner)) + { + Owner->Modify(); + if (AS) + { + AS->DestroyActor(Owner); + } + else + { + World->DestroyActor(Owner); + } + Destroyed.Add(Owner); + } + } + else + { + C->Modify(); + C->DestroyComponent(); + } + } + } + } + + // Plan is now stale (originals consumed); require a re-scan before further ops. + if (bDestroyOriginals) + { + Groups.Reset(); + } + if (GEditor) + { + GEditor->RedrawLevelEditingViewports(); + } + UE_LOG(LogOptimizer, Log, TEXT("Optimizer HISM: %d actor(s) built."), Built); + return Built; +} + +void UOptimizerSubsystem::ClearPlan() +{ + Groups.Reset(); +} + +bool UOptimizerSubsystem::HasPlan() const +{ + return Groups.Num() > 0; +} diff --git a/Source/OptimizerEditor/Private/SOptimizerPanel.cpp b/Source/OptimizerEditor/Private/SOptimizerPanel.cpp new file mode 100644 index 0000000..370e484 --- /dev/null +++ b/Source/OptimizerEditor/Private/SOptimizerPanel.cpp @@ -0,0 +1,211 @@ +// Copyright IHY. +#include "SOptimizerPanel.h" + +#include "OptimizerSubsystem.h" + +#include "Editor.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SScrollBox.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Text/STextBlock.h" +#include "Styling/AppStyle.h" +#include "Styling/CoreStyle.h" + +#define LOCTEXT_NAMESPACE "OptimizerPanel" + +void SOptimizerPanel::Construct(const FArguments& InArgs) +{ + const FSlateFontInfo TitleFont = FCoreStyle::GetDefaultFontStyle("Bold", 15); + const FSlateFontInfo MonoFont = FCoreStyle::GetDefaultFontStyle("Mono", 9); + + ChildSlot + [ + SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) + .Padding(FMargin(14.f)) + [ + SNew(SVerticalBox) + + // Title + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 2) + [ + SNew(STextBlock) + .Text(LOCTEXT("Title", "Mesh Optimizer")) + .Font(TitleFont) + ] + + // Subtitle + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 12) + [ + SNew(STextBlock) + .Text(LOCTEXT("Subtitle", "Find sibling StaticMeshes, unify onto one canonical mesh, then build HISM.")) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()) + .AutoWrapText(true) + ] + + // Detection toggles + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 10) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth()[ MakeLabeledCheck(ChkMirror, LOCTEXT("Mirror", "Mirrored"), false) ] + + SHorizontalBox::Slot().AutoWidth()[ MakeLabeledCheck(ChkScale, LOCTEXT("Scale", "Scaled"), false) ] + + SHorizontalBox::Slot().AutoWidth()[ MakeLabeledCheck(ChkMergeMat, LOCTEXT("MergeMat", "Merge materials"), false) ] + ] + + // Action buttons + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 8) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 8, 0) + [ MakeButton(LOCTEXT("Scan", "Scan"), FOnClicked::CreateSP(this, &SOptimizerPanel::OnScan)) ] + + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 8, 0) + [ MakeButton(LOCTEXT("Unify", "Unify"), FOnClicked::CreateSP(this, &SOptimizerPanel::OnUnify)) ] + + SHorizontalBox::Slot().AutoWidth() + [ MakeButton(LOCTEXT("HISM", "Build HISM"), FOnClicked::CreateSP(this, &SOptimizerPanel::OnBuildHISM)) ] + ] + + // Destroy-originals toggle + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 12) + [ + MakeLabeledCheck(ChkDestroy, LOCTEXT("Destroy", "Destroy originals when building HISM"), true) + ] + + // Results area + + SVerticalBox::Slot().FillHeight(1.f) + [ + SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.DarkGroupBorder")) + .Padding(FMargin(8.f)) + [ + SNew(SScrollBox) + + SScrollBox::Slot() + [ + SAssignNew(ResultsBox, STextBlock) + .Text(LOCTEXT("Idle", "Press Scan to analyze the level.")) + .Font(MonoFont) + .AutoWrapText(true) + ] + ] + ] + ] + ]; +} + +TSharedRef SOptimizerPanel::MakeLabeledCheck(TSharedPtr& Out, const FText& Label, bool bDefault) +{ + return SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ + SAssignNew(Out, SCheckBox) + .IsChecked(bDefault ? ECheckBoxState::Checked : ECheckBoxState::Unchecked) + ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(5, 0, 16, 0) + [ + SNew(STextBlock).Text(Label) + ]; +} + +TSharedRef SOptimizerPanel::MakeButton(const FText& Label, FOnClicked OnClicked) +{ + return SNew(SButton) + .Text(Label) + .OnClicked(OnClicked) + .ContentPadding(FMargin(18.f, 6.f)) + .HAlign(HAlign_Center); +} + +bool SOptimizerPanel::IsChecked(const TSharedPtr& Chk) const +{ + return Chk.IsValid() && Chk->IsChecked(); +} + +void SOptimizerPanel::SetReport(const FString& Text) +{ + if (ResultsBox.IsValid()) + { + ResultsBox->SetText(FText::FromString(Text)); + } +} + +FReply SOptimizerPanel::OnScan() +{ + UOptimizerSubsystem* SS = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!SS) + { + SetReport(TEXT("Subsystem unavailable.")); + return FReply::Handled(); + } + + FOptimizerScanSettings Settings; + Settings.bAllowMirrored = IsChecked(ChkMirror); + Settings.bAllowUniformScale = IsChecked(ChkScale); + Settings.bMergeAcrossMaterials = IsChecked(ChkMergeMat); + + const FOptimizerScanResult R = SS->ScanLevel(Settings); + + FString Report = R.Summary + TEXT("\n"); + Report += FString::Printf(TEXT("actors=%d components=%d unique meshes=%d\n\n"), + R.ActorsScanned, R.ComponentsScanned, R.UniqueMeshes); + + if (R.Groups.Num() == 0) + { + Report += TEXT("No sibling groups found."); + } + for (const FOptimizerGroupView& G : R.Groups) + { + FString Flags; + if (G.bHasMirrored) { Flags += TEXT(" [mirror]"); } + if (G.bHasScaled) { Flags += TEXT(" [scale]"); } + if (G.bMaterialMismatch) { Flags += TEXT(" [mat!=]"); } + if (G.ShearRejectedCount > 0) { Flags += FString::Printf(TEXT(" [shear x%d]"), G.ShearRejectedCount); } + + Report += FString::Printf(TEXT("- %s (members=%d, placements=%d, fix=%d, maxDev=%.4f cm)%s\n %s\n"), + *G.CanonicalMeshName, G.MemberMeshNames.Num(), G.InstanceCount, G.TransformFixCount, + G.MaxDeviation, *Flags, *FString::Join(G.MemberMeshNames, TEXT(", "))); + } + + SetReport(Report); + return FReply::Handled(); +} + +FReply SOptimizerPanel::OnUnify() +{ + UOptimizerSubsystem* SS = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!SS) + { + SetReport(TEXT("Subsystem unavailable.")); + return FReply::Handled(); + } + if (!SS->HasPlan()) + { + SetReport(TEXT("Run Scan first.")); + return FReply::Handled(); + } + const int32 N = SS->ApplyUnify(); + SetReport(FString::Printf(TEXT("Unify done: %d placement(s) reassigned to canonical mesh + corrected transform.\nUndo with Ctrl+Z. Re-scan before Build HISM."), N)); + return FReply::Handled(); +} + +FReply SOptimizerPanel::OnBuildHISM() +{ + UOptimizerSubsystem* SS = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!SS) + { + SetReport(TEXT("Subsystem unavailable.")); + return FReply::Handled(); + } + if (!SS->HasPlan()) + { + SetReport(TEXT("Run Scan first.")); + return FReply::Handled(); + } + const bool bDestroy = IsChecked(ChkDestroy); + const int32 N = SS->BuildHISM(bDestroy); + SetReport(FString::Printf(TEXT("Build HISM done: %d HISM actor(s) created%s.\nUndo with Ctrl+Z."), + N, bDestroy ? TEXT(" (originals destroyed)") : TEXT(""))); + return FReply::Handled(); +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/OptimizerEditor/Private/SOptimizerPanel.h b/Source/OptimizerEditor/Private/SOptimizerPanel.h new file mode 100644 index 0000000..90b3fd0 --- /dev/null +++ b/Source/OptimizerEditor/Private/SOptimizerPanel.h @@ -0,0 +1,40 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" +#include "Widgets/SCompoundWidget.h" + +class STextBlock; +class SCheckBox; +class SWidget; + +/** + * Native Slate panel for the Mesh Optimizer (hosted in a nomad tab). Drives UOptimizerSubsystem: + * Scan (dry-run report) / Unify (reassign siblings) / Build HISM (collapse). Uses editor styling + * so it reads as a native tool rather than raw UMG. + */ +class SOptimizerPanel : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SOptimizerPanel) {} + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs); + +private: + TSharedRef MakeLabeledCheck(TSharedPtr& Out, const FText& Label, bool bDefault); + TSharedRef MakeButton(const FText& Label, FOnClicked OnClicked); + + FReply OnScan(); + FReply OnUnify(); + FReply OnBuildHISM(); + + void SetReport(const FString& Text); + bool IsChecked(const TSharedPtr& Chk) const; + + TSharedPtr ChkMirror; + TSharedPtr ChkScale; + TSharedPtr ChkMergeMat; + TSharedPtr ChkDestroy; + TSharedPtr ResultsBox; +}; diff --git a/Source/OptimizerEditor/Public/OptimizerEditorModule.h b/Source/OptimizerEditor/Public/OptimizerEditorModule.h new file mode 100644 index 0000000..70889d6 --- /dev/null +++ b/Source/OptimizerEditor/Public/OptimizerEditorModule.h @@ -0,0 +1,26 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleInterface.h" + +/** + * Editor-only module for the Mesh Optimizer. + * + * Registers a Tools-menu entry + nomad tab hosting the native Slate panel (SOptimizerPanel), + * which drives UOptimizerSubsystem: scan level StaticMesh usage, group geometrically-identical + * "sibling" meshes, rebase every user onto one canonical mesh with a pivot/rotation-corrected + * transform, and optionally collapse the group into a HierarchicalInstancedStaticMesh. + */ +class FOptimizerEditorModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + void RegisterMenus(); // deferred via UToolMenus startup callback + TSharedRef SpawnPanelTab(const class FSpawnTabArgs& Args); + + bool bTabRegistered = false; +}; diff --git a/Source/OptimizerEditor/Public/OptimizerSubsystem.h b/Source/OptimizerEditor/Public/OptimizerSubsystem.h new file mode 100644 index 0000000..1dc7863 --- /dev/null +++ b/Source/OptimizerEditor/Public/OptimizerSubsystem.h @@ -0,0 +1,76 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "OptimizerTypes.h" +#include "OptimizerSubsystem.generated.h" + +class UStaticMesh; +class UStaticMeshComponent; + +/** + * Editor subsystem driving the Mesh Optimizer pipeline. The Editor Utility Widget calls these: + * 1. ScanLevel -> dry-run plan (sibling groups + per-placement corrected transforms) + * 2. ApplyUnify -> reassign every sibling placement to its canonical mesh with the fixed transform + * 3. BuildHISM -> collapse each group into one HierarchicalInstancedStaticMesh + * Steps 2 and 3 each run inside a single undoable transaction. BuildHISM is independent of ApplyUnify + * (it uses the canonical mesh + planned transforms directly). + */ +UCLASS() +class OPTIMIZEREDITOR_API UOptimizerSubsystem : public UEditorSubsystem +{ + GENERATED_BODY() + +public: + /** Dry run: scan the level, group sibling meshes, plan corrected transforms. Mutates nothing. */ + UFUNCTION(BlueprintCallable, Category = "Optimizer") + FOptimizerScanResult ScanLevel(const FOptimizerScanSettings& Settings); + + /** Reassign all non-instanced sibling placements to their canonical mesh + corrected transform. Returns count changed. */ + UFUNCTION(BlueprintCallable, Category = "Optimizer") + int32 ApplyUnify(); + + /** Build one HISM actor per group from the planned transforms. Returns number of HISM actors created. */ + UFUNCTION(BlueprintCallable, Category = "Optimizer") + int32 BuildHISM(bool bDestroyOriginals = true); + + /** Discard the current plan. */ + UFUNCTION(BlueprintCallable, Category = "Optimizer") + void ClearPlan(); + + UFUNCTION(BlueprintCallable, Category = "Optimizer") + bool HasPlan() const; + +private: + /** One placement of a mesh in the level (a component, or one instance inside an ISM/HISM). */ + struct FPlacement + { + TWeakObjectPtr Component; + int32 InstanceIndex = INDEX_NONE; // >= 0 when this is an instance inside an ISM/HISM + TWeakObjectPtr Mesh; + FTransform World = FTransform::Identity; + FTransform PlannedWorld = FTransform::Identity; + bool bNeedsTransformFix = false; + bool bShearRejected = false; + bool bInstanced = false; // inside an ISM/HISM -> ApplyUnify can't swap it (HISM-only) + }; + + /** A sibling group: distinct meshes that share geometry, collapsed onto one canonical. */ + struct FGroup + { + TArray> Members; + TWeakObjectPtr Canonical; + TArray Placements; + float MaxDeviation = 0.0f; + bool bHasMirrored = false; + bool bHasScaled = false; + bool bMaterialMismatch = false; + int32 ShearRejected = 0; + }; + + TArray Groups; + FOptimizerScanSettings LastSettings; + + FOptimizerScanResult BuildResultView() const; +}; diff --git a/Source/OptimizerEditor/Public/OptimizerTypes.h b/Source/OptimizerEditor/Public/OptimizerTypes.h new file mode 100644 index 0000000..bb3b5d8 --- /dev/null +++ b/Source/OptimizerEditor/Public/OptimizerTypes.h @@ -0,0 +1,157 @@ +// Copyright IHY. +#pragma once + +#include "CoreMinimal.h" +#include "OptimizerTypes.generated.h" + +/** + * Shared data contract between the C++ optimizer core and the Editor Utility Widget UI. + * BlueprintType structs/enums here are what the EUW reads/writes; the heavy internal + * algorithm structures (point clouds, recovered deltas) live in the matcher/reconciler. + */ + +/** Which placements to scan. v1 = level StaticMeshActors / standalone components. */ +UENUM(BlueprintType) +enum class EOptimizerScanScope : uint8 +{ + SelectedActors UMETA(DisplayName = "Selected Actors"), + WholeLevel UMETA(DisplayName = "Whole Level (loaded)") +}; + +/** How aggressively to treat two meshes as the same geometry. */ +UENUM(BlueprintType) +enum class EOptimizerMatchPrecision : uint8 +{ + // Identical geometry AND identical orientation/pivot (recovered delta D == Identity). + ExactOnly UMETA(DisplayName = "Exact duplicates only"), + // Identical geometry up to a rigid transform (rotation and/or baked pivot offset). + RigidTransforms UMETA(DisplayName = "Exact + rotated/pivot (rigid)") +}; + +/** How to pick the surviving canonical mesh within a sibling group. */ +UENUM(BlueprintType) +enum class EOptimizerCanonicalPolicy : uint8 +{ + MostInstances UMETA(DisplayName = "Most-used mesh"), + LowestVertexCount UMETA(DisplayName = "Lowest vertex count"), + FirstAlphabetical UMETA(DisplayName = "First alphabetical") +}; + +/** User-facing scan configuration. */ +USTRUCT(BlueprintType) +struct FOptimizerScanSettings +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scope") + EOptimizerScanScope Scope = EOptimizerScanScope::WholeLevel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Detection") + EOptimizerMatchPrecision Precision = EOptimizerMatchPrecision::RigidTransforms; + + /** Treat negative-scale (mirrored) variants as siblings. They become their own canonical/HISM. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Detection") + bool bAllowMirrored = false; + + /** Treat uniformly-scaled copies as siblings (instance carries the scale). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Detection") + bool bAllowUniformScale = false; + + /** Merge geometry siblings even when their material sets differ (canonical's materials win — risky). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Detection") + bool bMergeAcrossMaterials = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Canonical") + EOptimizerCanonicalPolicy CanonicalPolicy = EOptimizerCanonicalPolicy::MostInstances; + + /** Position weld grid, cm. Snaps near-coincident verts so seam-splitting doesn't change counts. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced|Tolerances", AdvancedDisplay, meta = (ClampMin = "0.0")) + float WeldEpsilon = 0.01f; + + /** Max per-vertex world deviation to ACCEPT a match, cm. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced|Tolerances", AdvancedDisplay, meta = (ClampMin = "0.0")) + float AcceptTolerance = 0.01f; + + /** Relative tolerance for scalar fingerprint features (area / volume / eigenvalues). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced|Tolerances", AdvancedDisplay, meta = (ClampMin = "0.0")) + float ScalarRelTolerance = 0.001f; + + /** Eigenvalue-gap fraction below which PCA orientation is considered degenerate -> brute-24 fallback. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced|Tolerances", AdvancedDisplay, meta = (ClampMin = "0.0")) + float DegenerateEigenEpsilon = 0.05f; +}; + +/** One sibling group, summarised for the UI list. */ +USTRUCT(BlueprintType) +struct FOptimizerGroupView +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 GroupId = -1; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + FString CanonicalMeshName; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + FString CanonicalMeshPath; + + /** Distinct sibling assets in the group (includes the canonical). */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + TArray MemberMeshNames; + + /** Total placements (actors/components) that will collapse onto the canonical. */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 InstanceCount = 0; + + /** Placements that need a corrected transform (W' != W). */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 TransformFixCount = 0; + + /** Worst per-vertex verification deviation across the group, cm. */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + float MaxDeviation = 0.0f; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + bool bHasMirrored = false; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + bool bHasScaled = false; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + bool bMaterialMismatch = false; + + /** Placements skipped because the corrective transform would shear (non-uniform scale + rotation). */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 ShearRejectedCount = 0; +}; + +/** Result of a dry-run scan: the plan the user reviews before applying. */ +USTRUCT(BlueprintType) +struct FOptimizerScanResult +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + TArray Groups; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 ActorsScanned = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 ComponentsScanned = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 UniqueMeshes = 0; + + /** Placements removable by collapsing siblings (sum over groups of InstanceCount - 1 distinct survivors). */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + int32 InstancesCollapsible = 0; + + /** True if the world is partitioned and some cells were unloaded -> coverage is partial. */ + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + bool bWorldPartitionCoverageWarning = false; + + UPROPERTY(BlueprintReadOnly, Category = "Optimizer") + FString Summary; +};