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 <noreply@anthropic.com>
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# Unreal Engine build artifacts
|
||||
Binaries/
|
||||
Intermediate/
|
||||
DerivedDataCache/
|
||||
Saved/
|
||||
|
||||
# IDE / OS
|
||||
.vs/
|
||||
*.pdb
|
||||
*.VC.db
|
||||
.DS_Store
|
||||
21
Optimizer.uplugin
Normal file
21
Optimizer.uplugin
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
153
README.md
Normal file
153
README.md
Normal file
@ -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 (`<Project>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.
|
||||
39
Source/OptimizerEditor/OptimizerEditor.Build.cs
Normal file
39
Source/OptimizerEditor/OptimizerEditor.Build.cs
Normal file
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
79
Source/OptimizerEditor/Private/OptimizerEditorModule.cpp
Normal file
79
Source/OptimizerEditor/Private/OptimizerEditorModule.cpp
Normal file
@ -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<SDockTab> FOptimizerEditorModule::SpawnPanelTab(const FSpawnTabArgs& Args)
|
||||
{
|
||||
return SNew(SDockTab)
|
||||
.TabRole(ETabRole::NomadTab)
|
||||
[
|
||||
SNew(SOptimizerPanel)
|
||||
];
|
||||
}
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
292
Source/OptimizerEditor/Private/OptimizerGeometry.cpp
Normal file
292
Source/OptimizerEditor/Private/OptimizerGeometry.cpp
Normal file
@ -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<FVector>& OutRaw, TArray<FIntVector>& 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<FVector3f> Positions = Attributes.GetVertexPositions();
|
||||
|
||||
OutRaw.Reset(MD->Vertices().Num());
|
||||
TMap<FVertexID, int32> 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<const FVertexID> 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<FVector>& OutRaw, TArray<FIntVector>& 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<uint32> 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<FVector> Raw;
|
||||
TArray<FIntVector> 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<FIntVector, int32> Grid;
|
||||
Grid.Reserve(Raw.Num());
|
||||
TArray<FVector> 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<FStaticMaterial>& 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;
|
||||
}
|
||||
}
|
||||
53
Source/OptimizerEditor/Private/OptimizerGeometry.h
Normal file
53
Source/OptimizerEditor/Private/OptimizerGeometry.h
Normal file
@ -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<FVector> 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]);
|
||||
}
|
||||
347
Source/OptimizerEditor/Private/OptimizerMatcher.cpp
Normal file
347
Source/OptimizerEditor/Private/OptimizerMatcher.cpp
Normal file
@ -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<FRot3>& AxisRotations()
|
||||
{
|
||||
static TArray<FRot3> 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<FVector>* Pts = nullptr;
|
||||
TMap<FIntVector, TArray<int32>> Cells;
|
||||
|
||||
void Build(const TArray<FVector>& 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<int32>* 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<FVector>& 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<FVector>& A, const TArray<FVector>& 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<double>::Max(); FRot3 R; double s = 1.0; };
|
||||
FBest Best;
|
||||
|
||||
// Candidate set.
|
||||
TArray<const FRot3*> 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;
|
||||
}
|
||||
}
|
||||
35
Source/OptimizerEditor/Private/OptimizerMatcher.h
Normal file
35
Source/OptimizerEditor/Private/OptimizerMatcher.h
Normal file
@ -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);
|
||||
}
|
||||
40
Source/OptimizerEditor/Private/OptimizerReconciler.cpp
Normal file
40
Source/OptimizerEditor/Private/OptimizerReconciler.cpp
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
27
Source/OptimizerEditor/Private/OptimizerReconciler.h
Normal file
27
Source/OptimizerEditor/Private/OptimizerReconciler.h
Normal file
@ -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);
|
||||
}
|
||||
565
Source/OptimizerEditor/Private/OptimizerSubsystem.cpp
Normal file
565
Source/OptimizerEditor/Private/OptimizerSubsystem.cpp
Normal file
@ -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<UStaticMesh*>& Members,
|
||||
const TMap<UStaticMesh*, FOptMeshGeom>& Geoms,
|
||||
const TMap<UStaticMesh*, int32>& 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<FPlacement> Placements;
|
||||
UEditorActorSubsystem* AS = GEditor->GetEditorSubsystem<UEditorActorSubsystem>();
|
||||
TArray<AActor*> Actors;
|
||||
if (AS)
|
||||
{
|
||||
Actors = (Settings.Scope == EOptimizerScanScope::SelectedActors)
|
||||
? AS->GetSelectedLevelActors()
|
||||
: AS->GetAllLevelActors();
|
||||
}
|
||||
Result.ActorsScanned = Actors.Num();
|
||||
|
||||
for (AActor* Actor : Actors)
|
||||
{
|
||||
if (!Actor)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
TArray<UStaticMeshComponent*> SMCs;
|
||||
Actor->GetComponents<UStaticMeshComponent>(SMCs);
|
||||
for (UStaticMeshComponent* SMC : SMCs)
|
||||
{
|
||||
// ISM/HISM sources are out of v1 scope (avoids per-instance removal complexity).
|
||||
if (SMC->IsA<UInstancedStaticMeshComponent>())
|
||||
{
|
||||
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<UStaticMesh*> UniqueSet;
|
||||
for (const FPlacement& P : Placements)
|
||||
{
|
||||
if (P.Mesh.IsValid())
|
||||
{
|
||||
UniqueSet.Add(P.Mesh.Get());
|
||||
}
|
||||
}
|
||||
TArray<UStaticMesh*> Unique = UniqueSet.Array();
|
||||
Result.UniqueMeshes = Unique.Num();
|
||||
|
||||
TMap<UStaticMesh*, FOptMeshGeom> 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<UStaticMesh*, int32> MeshInstanceCount;
|
||||
for (const FPlacement& P : Placements)
|
||||
{
|
||||
if (P.Mesh.IsValid())
|
||||
{
|
||||
MeshInstanceCount.FindOrAdd(P.Mesh.Get())++;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Bucket by (welded vertex count, triangle count) ---
|
||||
TMap<TPair<int32, int32>, TArray<UStaticMesh*>> Buckets;
|
||||
for (UStaticMesh* M : Unique)
|
||||
{
|
||||
const FOptMeshGeom* G = Geoms.Find(M);
|
||||
if (G && G->bValid)
|
||||
{
|
||||
Buckets.FindOrAdd(TPair<int32, int32>(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<UStaticMesh*>& Bucket = BucketPair.Value;
|
||||
if (Bucket.Num() < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (UStaticMesh* M : Bucket)
|
||||
{
|
||||
EnsurePositions(M);
|
||||
}
|
||||
|
||||
const int32 K = Bucket.Num();
|
||||
TArray<int32> 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<int32, TArray<UStaticMesh*>> Components;
|
||||
for (int32 i = 0; i < K; ++i)
|
||||
{
|
||||
Components.FindOrAdd(Find(i)).Add(Bucket[i]);
|
||||
}
|
||||
|
||||
for (auto& CompPair : Components)
|
||||
{
|
||||
TArray<UStaticMesh*>& 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<UStaticMesh*, FOptDelta> 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<UStaticMesh>& 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<UEditorActorSubsystem>();
|
||||
const FScopedTransaction Transaction(NSLOCTEXT("Optimizer", "HISM", "Optimizer: build HISM"));
|
||||
int32 Built = 0;
|
||||
TSet<AActor*> Destroyed;
|
||||
|
||||
for (FGroup& G : Groups)
|
||||
{
|
||||
UStaticMesh* Canon = G.Canonical.Get();
|
||||
if (!Canon)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
TArray<FTransform> 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>(AActor::StaticClass(), FTransform::Identity, Sp);
|
||||
if (!Holder)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
UHierarchicalInstancedStaticMeshComponent* HISM =
|
||||
NewObject<UHierarchicalInstancedStaticMeshComponent>(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<AStaticMeshActor>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
211
Source/OptimizerEditor/Private/SOptimizerPanel.cpp
Normal file
211
Source/OptimizerEditor/Private/SOptimizerPanel.cpp
Normal file
@ -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<SWidget> SOptimizerPanel::MakeLabeledCheck(TSharedPtr<SCheckBox>& 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<SWidget> 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<SCheckBox>& 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<UOptimizerSubsystem>() : 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<UOptimizerSubsystem>() : 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<UOptimizerSubsystem>() : 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
|
||||
40
Source/OptimizerEditor/Private/SOptimizerPanel.h
Normal file
40
Source/OptimizerEditor/Private/SOptimizerPanel.h
Normal file
@ -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<SWidget> MakeLabeledCheck(TSharedPtr<SCheckBox>& Out, const FText& Label, bool bDefault);
|
||||
TSharedRef<SWidget> MakeButton(const FText& Label, FOnClicked OnClicked);
|
||||
|
||||
FReply OnScan();
|
||||
FReply OnUnify();
|
||||
FReply OnBuildHISM();
|
||||
|
||||
void SetReport(const FString& Text);
|
||||
bool IsChecked(const TSharedPtr<SCheckBox>& Chk) const;
|
||||
|
||||
TSharedPtr<SCheckBox> ChkMirror;
|
||||
TSharedPtr<SCheckBox> ChkScale;
|
||||
TSharedPtr<SCheckBox> ChkMergeMat;
|
||||
TSharedPtr<SCheckBox> ChkDestroy;
|
||||
TSharedPtr<STextBlock> ResultsBox;
|
||||
};
|
||||
26
Source/OptimizerEditor/Public/OptimizerEditorModule.h
Normal file
26
Source/OptimizerEditor/Public/OptimizerEditorModule.h
Normal file
@ -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<class SDockTab> SpawnPanelTab(const class FSpawnTabArgs& Args);
|
||||
|
||||
bool bTabRegistered = false;
|
||||
};
|
||||
76
Source/OptimizerEditor/Public/OptimizerSubsystem.h
Normal file
76
Source/OptimizerEditor/Public/OptimizerSubsystem.h
Normal file
@ -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<UStaticMeshComponent> Component;
|
||||
int32 InstanceIndex = INDEX_NONE; // >= 0 when this is an instance inside an ISM/HISM
|
||||
TWeakObjectPtr<UStaticMesh> 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<TWeakObjectPtr<UStaticMesh>> Members;
|
||||
TWeakObjectPtr<UStaticMesh> Canonical;
|
||||
TArray<FPlacement> Placements;
|
||||
float MaxDeviation = 0.0f;
|
||||
bool bHasMirrored = false;
|
||||
bool bHasScaled = false;
|
||||
bool bMaterialMismatch = false;
|
||||
int32 ShearRejected = 0;
|
||||
};
|
||||
|
||||
TArray<FGroup> Groups;
|
||||
FOptimizerScanSettings LastSettings;
|
||||
|
||||
FOptimizerScanResult BuildResultView() const;
|
||||
};
|
||||
157
Source/OptimizerEditor/Public/OptimizerTypes.h
Normal file
157
Source/OptimizerEditor/Public/OptimizerTypes.h
Normal file
@ -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<FString> 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<FOptimizerGroupView> 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;
|
||||
};
|
||||
Reference in New Issue
Block a user