From 018c7cf33df262c3ac0c7b4ff699457562a8a6b2 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Mon, 1 Jun 2026 19:19:50 +0200 Subject: [PATCH 01/10] Update editor icons atlas with new folder color and add original `.psd` file --- Content/Editor/IconsAtlas.flax | 4 ++-- Content/Editor/IconsAtlas.psd | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 Content/Editor/IconsAtlas.psd diff --git a/Content/Editor/IconsAtlas.flax b/Content/Editor/IconsAtlas.flax index cd1c745a2..01baadfed 100644 --- a/Content/Editor/IconsAtlas.flax +++ b/Content/Editor/IconsAtlas.flax @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2248c3069f16a3b1eb62aa4660c81427fd6effa364f8f0694ba751be8e60114c -size 5612622 +oid sha256:c2a7d0c6969a180d59a32fbc908fe432bddb393437e9c5b64ddb25737e4aab94 +size 5609840 diff --git a/Content/Editor/IconsAtlas.psd b/Content/Editor/IconsAtlas.psd new file mode 100644 index 000000000..b062fc0fa --- /dev/null +++ b/Content/Editor/IconsAtlas.psd @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ee4e46c2b39cf6def6be079a898204e283253bf5841ccf3985fa9d49834b9a0 +size 2766306 From 1badeda31cb0ab2158373a11df425e3635a15a26 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Mon, 1 Jun 2026 19:20:00 +0200 Subject: [PATCH 02/10] Fix error when drawing animated model thumbnail --- .../Viewport/Previews/AnimatedModelPreview.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Source/Editor/Viewport/Previews/AnimatedModelPreview.cs b/Source/Editor/Viewport/Previews/AnimatedModelPreview.cs index 58df5f836..a5ff3758b 100644 --- a/Source/Editor/Viewport/Previews/AnimatedModelPreview.cs +++ b/Source/Editor/Viewport/Previews/AnimatedModelPreview.cs @@ -216,16 +216,17 @@ namespace FlaxEditor.Viewport.Previews _showFloorButton = ViewWidgetShowMenu.AddButton("Floor", button => ShowFloor = !ShowFloor); _showFloorButton.IndexInParent = 1; _showFloorButton.CloseMenuOnClick = false; - } - _nodeNameSizeButton = ViewWidgetButtonMenu.AddButton("Skeleton Names Size"); - _nodeNameSizeButton.CloseMenuOnClick = false; - var nodeNameSizeValue = new IntValueBox(NodeNamesSize, 118, 2, 70.0f, 1, 32) - { - Parent = _nodeNameSizeButton - }; - _nodeNameSizeButton.Enabled = ShowNodesNames; - nodeNameSizeValue.ValueChanged += () => NodeNamesSize = nodeNameSizeValue.Value; + // Skeleton Names Size + _nodeNameSizeButton = ViewWidgetButtonMenu.AddButton("Skeleton Names Size"); + _nodeNameSizeButton.CloseMenuOnClick = false; + var nodeNameSizeValue = new IntValueBox(NodeNamesSize, 118, 2, 70.0f, 1, 32) + { + Parent = _nodeNameSizeButton + }; + _nodeNameSizeButton.Enabled = ShowNodesNames; + nodeNameSizeValue.ValueChanged += () => NodeNamesSize = nodeNameSizeValue.Value; + } // Enable shadows PreviewLight.ShadowsMode = ShadowsCastingMode.All; From ff526ecafb496b7185d9774b246fd146f33f0160 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Tue, 2 Jun 2026 13:23:21 +0200 Subject: [PATCH 03/10] Fix nested prefab stack overflow when adding new object to nested prefabs hierarchy https://github.com/LOOPDISK/FlaxEngine/pull/44 --- Source/Engine/Level/Actors/AnimatedModel.cpp | 3 +- Source/Engine/Level/Prefabs/Prefab.Apply.cpp | 60 +++++---- Source/Engine/Level/Prefabs/Prefab.h | 2 +- Source/Engine/Level/SceneObjectsFactory.cpp | 2 +- Source/Engine/Tests/TestPrefabs.cpp | 121 +++++++++++++++++++ 5 files changed, 159 insertions(+), 29 deletions(-) diff --git a/Source/Engine/Level/Actors/AnimatedModel.cpp b/Source/Engine/Level/Actors/AnimatedModel.cpp index ce106dc83..bc3c5b8fb 100644 --- a/Source/Engine/Level/Actors/AnimatedModel.cpp +++ b/Source/Engine/Level/Actors/AnimatedModel.cpp @@ -820,8 +820,7 @@ void AnimatedModel::RunBlendShapeDeformer(const MeshBase* mesh, MeshDeformationD void AnimatedModel::BeginPlay(SceneBeginData* data) { - if (SkinnedModel && SkinnedModel->IsLoaded()) - PreInitSkinningData(); + PreInitSkinningData(); // Base ModelInstanceActor::BeginPlay(data); diff --git a/Source/Engine/Level/Prefabs/Prefab.Apply.cpp b/Source/Engine/Level/Prefabs/Prefab.Apply.cpp index cb6c82b5d..d972ee520 100644 --- a/Source/Engine/Level/Prefabs/Prefab.Apply.cpp +++ b/Source/Engine/Level/Prefabs/Prefab.Apply.cpp @@ -24,6 +24,7 @@ #include "Engine/Profiler/ProfilerCPU.h" #include "Engine/Threading/MainThreadTask.h" #include "Editor/Editor.h" +#include "FlaxEngine.Gen.h" // Apply flow: // - collect all prefabs using this prefab (load and create default instances) @@ -772,7 +773,13 @@ bool Prefab::ApplyAll(Actor* targetActor) if (ApplyAllInternal(targetActor, true, thisPrefabInstancesData)) return true; - SyncNestedPrefabs(allPrefabs, allPrefabsInstancesData); + // Sync nested prefabs + if (allPrefabs.HasItems()) + { + LOG(Info, "Updating referencing prefabs"); + HashSet synced; + SyncNestedPrefabs(allPrefabs, allPrefabsInstancesData, synced); + } const auto endTime = DateTime::NowUTC(); LOG(Info, "Prefab updated! {0} ms", (int32)(endTime - startTime).GetTotalMilliseconds()); @@ -1027,8 +1034,14 @@ bool Prefab::ApplyAllInternal(Actor* targetActor, bool linkTargetActorObjectToPr rapidjson_flax::Document targetDataDocument; if (NestedPrefabs.HasItems()) { + // Use initial data buffer (unstripped) but reorder objects to match the sequence (eg. when new object was added to the nested prefab) targetDataDocument.Parse(dataBuffer.GetString(), dataBuffer.GetSize()); - SceneObjectsFactory::PrefabSyncData prefabSyncData(*sceneObjects.Value, targetDataDocument, modifier.Value); + Array reorderedObjects = *sceneObjects.Value; + newPrefabInstanceIdToDataIndexCounter = 0; + for (auto i = newPrefabInstanceIdToDataIndex.Begin(); i.IsNotEnd(); ++i) + reorderedObjects.Insert(i->Value, sceneObjects->At(newPrefabInstanceIdToDataIndexStart + newPrefabInstanceIdToDataIndexCounter++)); + reorderedObjects.Resize(sceneObjects.Value->Count()); // reorderedObjects matches order in targetDataDocument + SceneObjectsFactory::PrefabSyncData prefabSyncData(reorderedObjects, targetDataDocument, modifier.Value); SceneObjectsFactory::SetupPrefabInstances(context, prefabSyncData); if (context.Instances.HasItems()) @@ -1236,7 +1249,7 @@ bool Prefab::UpdateInternal(const Array& defaultInstanceObjects, r { return Init(TypeName, StringAnsiView(tmpBuffer.GetString(), (int32)tmpBuffer.GetSize())); } -#if 1 // Set to 0 to use memory-only reload that does not modifies the source file - useful for testing and debugging prefabs apply +#if 1 // Set to 0 to use memory-only reload that does not modify the source file - useful for testing and debugging prefabs apply #if COMPILE_WITH_ASSETS_IMPORTER Locker.Unlock(); @@ -1295,7 +1308,7 @@ bool Prefab::UpdateInternal(const Array& defaultInstanceObjects, r _defaultInstance->DeleteObject(); _defaultInstance = nullptr; } - _isLoaded = false; + _loadState = 0; // Update prefab data manually (to prevent updating source asset file - just for testing) Document.Parse(buffer.GetString(), buffer.GetSize()); @@ -1348,7 +1361,7 @@ bool Prefab::UpdateInternal(const Array& defaultInstanceObjects, r NestedPrefabs.Add(prefabId); } } - _isLoaded = true; + _loadState = 1; } #endif @@ -1395,34 +1408,31 @@ bool Prefab::SyncChangesInternal(PrefabInstancesData& prefabInstancesData) return ApplyAllInternal(targetActor, false, prefabInstancesData); } -void Prefab::SyncNestedPrefabs(const NestedPrefabsList& allPrefabs, Array& allPrefabsInstancesData) const +void Prefab::SyncNestedPrefabs(const NestedPrefabsList& allPrefabs, Array& allPrefabsInstancesData, HashSet& synced) const { PROFILE_CPU(); - LOG(Info, "Updating referencing prefabs"); - - // TODO: this may not work well for very complex prefab nesting -> loop order matters, maybe build a graph of dependencies? // Call recursive for all referencing prefab assets to refresh nested prefabs for (int32 i = 0; i < allPrefabs.Count(); i++) { - auto nestedPrefab = allPrefabs[i].Get(); - if (nestedPrefab) + Prefab* nestedPrefab = allPrefabs[i].Get(); + if (!nestedPrefab || synced.Contains(nestedPrefab->GetID())) + continue; + if (nestedPrefab->WaitForLoaded()) { - if (nestedPrefab->WaitForLoaded()) - { - LOG(Warning, "Waiting for prefab asset load failed."); - continue; - } + LOG(Warning, "Waiting for '{}' load failed.", nestedPrefab->ToString()); + continue; + } - // Sync only if prefab is used by this prefab (directly) and it has been captured before - const int32 nestedPrefabIndex = nestedPrefab->NestedPrefabs.Find(GetID()); - if (nestedPrefabIndex != -1) - { - if (nestedPrefab->SyncChangesInternal(allPrefabsInstancesData[i])) - continue; - nestedPrefab->SyncNestedPrefabs(allPrefabs, allPrefabsInstancesData); - ObjectsRemovalService::Flush(); - } + // Sync only if prefab is used by this prefab (directly) and it has been captured before + const int32 nestedPrefabIndex = nestedPrefab->NestedPrefabs.Find(GetID()); + if (nestedPrefabIndex != -1) + { + synced.Add(nestedPrefab->GetID()); + if (nestedPrefab->SyncChangesInternal(allPrefabsInstancesData[i])) + continue; + nestedPrefab->SyncNestedPrefabs(allPrefabs, allPrefabsInstancesData, synced); + ObjectsRemovalService::Flush(); } } } diff --git a/Source/Engine/Level/Prefabs/Prefab.h b/Source/Engine/Level/Prefabs/Prefab.h index cde99a2cb..b1200ca29 100644 --- a/Source/Engine/Level/Prefabs/Prefab.h +++ b/Source/Engine/Level/Prefabs/Prefab.h @@ -104,7 +104,7 @@ private: bool ApplyAllInternal(Actor* targetActor, bool linkTargetActorObjectToPrefab, PrefabInstancesData& prefabInstancesData); bool UpdateInternal(const Array& defaultInstanceObjects, rapidjson_flax::StringBuffer& tmpBuffer); bool SyncChangesInternal(PrefabInstancesData& prefabInstancesData); - void SyncNestedPrefabs(const NestedPrefabsList& allPrefabs, Array& allPrefabsInstancesData) const; + void SyncNestedPrefabs(const NestedPrefabsList& allPrefabs, Array& allPrefabsInstancesData, HashSet& synced) const; #endif void DeleteDefaultInstance(); diff --git a/Source/Engine/Level/SceneObjectsFactory.cpp b/Source/Engine/Level/SceneObjectsFactory.cpp index 59d23a994..e9931eb20 100644 --- a/Source/Engine/Level/SceneObjectsFactory.cpp +++ b/Source/Engine/Level/SceneObjectsFactory.cpp @@ -752,7 +752,7 @@ void SceneObjectsFactory::SynchronizePrefabInstances(Context& context, PrefabSyn obj->SetOrderInParent(order); } - // Setup hierarchy for the prefab instances (ensure any new objects are connected) + // Setup hierarchy for the prefab instances (after adding new objects to ensure they are connected, eg. when reparenting existing prefab into a new root) for (const auto& instance : context.Instances) { const auto& prefabStartData = data.Data[instance.StatIndex]; diff --git a/Source/Engine/Tests/TestPrefabs.cpp b/Source/Engine/Tests/TestPrefabs.cpp index 91bce81cc..fb34a7284 100644 --- a/Source/Engine/Tests/TestPrefabs.cpp +++ b/Source/Engine/Tests/TestPrefabs.cpp @@ -8,6 +8,7 @@ #include "Engine/Level/Actors/EmptyActor.h" #include "Engine/Level/Actors/DirectionalLight.h" #include "Engine/Level/Actors/ExponentialHeightFog.h" +#include "Engine/Level/Actors/AnimatedModel.h" #include "Engine/Level/Prefabs/Prefab.h" #include "Engine/Level/Prefabs/PrefabManager.h" #include "Engine/Scripting/ScriptingObjectReference.h" @@ -905,4 +906,124 @@ TEST_CASE("Prefabs") instance1->DeleteObject(); instance2->DeleteObject(); } + SECTION("Test Adding Object To Base Prefab") + { + // https://github.com/LOOPDISK/FlaxEngine/pull/44 + + // Create inner prefab with 3 objects in hierarchy + AssetReference prefabInner = Content::CreateVirtualAsset(); + REQUIRE(prefabInner); + Guid id; + Guid::Parse("15dbe4b0416be0777a6ce59e8788b10f", id); + prefabInner->ChangeID(id); + auto prefabInnerInit = prefabInner->Init(Prefab::TypeName, + "[" + "{" + "\"ID\": \"3de462104f56f681c14650a0171f88fb\"," + "\"TypeName\" : \"FlaxEngine.SpotLight\"," + "\"Name\" : \"Inner.Root\"" + "}," + "{" + "\"ID\": \"19b181f846b6911635ffacb902c93c6a\"," + "\"TypeName\" : \"FlaxEngine.StaticModel\"," + "\"ParentID\" : \"3de462104f56f681c14650a0171f88fb\"," + "\"Name\" : \"Inner.Cube\"" + "}," + "{" + "\"ID\": \"8950889f4a2e752d55165fbf10eaf184\"," + "\"TypeName\" : \"FlaxEngine.AnimatedModel\"," + "\"ParentID\" : \"19b181f846b6911635ffacb902c93c6a\"," + "\"Name\" : \"Inner.Model\"" + "}" + "]"); + REQUIRE(!prefabInnerInit); + + // Create outer prefab with 2 instances of inner prefab + AssetReference prefabOuter = Content::CreateVirtualAsset(); + REQUIRE(prefabOuter); + SCOPE_EXIT{ Content::DeleteAsset(prefabOuter); }; + Guid::Parse("2ab744714f746e31855f41815612d14b", id); + prefabOuter->ChangeID(id); + auto prefabOuterInit = prefabOuter->Init(Prefab::TypeName, + "[" + "{" + "\"ID\": \"dba7f4bb4acfd62608b9a8bf550f31a5\"," + "\"TypeName\": \"FlaxEngine.EmptyActor\"," + "\"Name\": \"Outer.Root\"" + "}," + "{" + "\"ID\": \"a3b705284432bed9f043829c04a2bc8f\"," + "\"PrefabID\": \"15dbe4b0416be0777a6ce59e8788b10f\"," + "\"PrefabObjectID\": \"3de462104f56f681c14650a0171f88fb\"," + "\"ParentID\": \"dba7f4bb4acfd62608b9a8bf550f31a5\"," + "\"Name\": \"Instance 1\"" + "}," + "{" + "\"ID\": \"06a8c15a41b822dd27f3ac9d79b142d3\"," + "\"PrefabID\": \"15dbe4b0416be0777a6ce59e8788b10f\"," + "\"PrefabObjectID\": \"19b181f846b6911635ffacb902c93c6a\"," + "\"ParentID\": \"a3b705284432bed9f043829c04a2bc8f\"" + "}," + "{" + "\"ID\": \"4759fb9e4c4dda3b61ab5ab43949e42f\"," + "\"PrefabID\": \"15dbe4b0416be0777a6ce59e8788b10f\"," + "\"PrefabObjectID\": \"8950889f4a2e752d55165fbf10eaf184\"," + "\"ParentID\": \"06a8c15a41b822dd27f3ac9d79b142d3\"" + "}," + "{" + "\"ID\": \"1225be664c0c081e714bbf93e09b99e4\"," + "\"PrefabID\": \"15dbe4b0416be0777a6ce59e8788b10f\"," + "\"PrefabObjectID\": \"3de462104f56f681c14650a0171f88fb\"," + "\"ParentID\": \"dba7f4bb4acfd62608b9a8bf550f31a5\"," + "\"Name\": \"Instance 2\"" + "}," + "{" + "\"ID\": \"b397243540322182b806ad8339b7b617\"," + "\"PrefabID\": \"15dbe4b0416be0777a6ce59e8788b10f\"," + "\"PrefabObjectID\": \"19b181f846b6911635ffacb902c93c6a\"," + "\"ParentID\": \"1225be664c0c081e714bbf93e09b99e4\"" + "}," + "{" + "\"ID\": \"2c3b8e824daf038a58df528a238ca2de\"," + "\"PrefabID\": \"15dbe4b0416be0777a6ce59e8788b10f\"," + "\"PrefabObjectID\": \"8950889f4a2e752d55165fbf10eaf184\"," + "\"ParentID\": \"b397243540322182b806ad8339b7b617\"" + "}" + "]"); + REQUIRE(!prefabOuterInit); + + // Spawn test instances of both prefabs + ScriptingObjectReference instanceInner = PrefabManager::SpawnPrefab(prefabInner); + ScriptingObjectReference instanceOuter = PrefabManager::SpawnPrefab(prefabOuter); + + // Add new object to the inner prefab + instanceInner->Children[0]->GetOrAddChild(); + + // Apply changes + bool applyResult = PrefabManager::ApplyAll(instanceInner); + REQUIRE(!applyResult); + + // Check state of outer instance to properly reflect hierarchy + REQUIRE(instanceOuter); + REQUIRE(instanceOuter->Children.Count() == 2); + REQUIRE(instanceOuter->Children[0] != nullptr); + REQUIRE(instanceOuter->Children[0]->Children.Count() == 1); + REQUIRE(instanceOuter->Children[0]->Children[0]); + REQUIRE(instanceOuter->Children[0]->Children[0]->Children.Count() == 2); + REQUIRE(instanceOuter->Children[0]->Children[0]->Children[0]->Is()); + REQUIRE(instanceOuter->Children[0]->Children[0]->Children[1]->Is()); + REQUIRE(instanceOuter->Children[1] != nullptr); + REQUIRE(instanceOuter->Children[1]->Children.Count() == 1); + REQUIRE(instanceOuter->Children[1]->Children[0]); + REQUIRE(instanceOuter->Children[1]->Children[0]->Children.Count() == 2); + REQUIRE(instanceOuter->Children[1]->Children[0]->Children[0]->Is()); + REQUIRE(instanceOuter->Children[0]->Children[0]->Children[1]->Is()); + REQUIRE(instanceOuter->Children[0]->Children[0] != instanceOuter->Children[1]->Children[0]); + REQUIRE(instanceOuter->Children[0]->Children[0]->Children[0] != instanceOuter->Children[1]->Children[0]->Children[0]); + REQUIRE(instanceOuter->Children[0]->Children[0]->Children[1] != instanceOuter->Children[1]->Children[0]->Children[1]); + + // Cleanup + instanceInner->DeleteObject(); + instanceOuter->DeleteObject(); + } } From a12c5e2203ca7eeccc45ae2fd65c87090918de5f Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Tue, 2 Jun 2026 13:24:10 +0200 Subject: [PATCH 04/10] Add more profiler events to assets code --- Source/Engine/Content/Asset.cpp | 1 + Source/Engine/Level/Prefabs/Prefab.cpp | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Source/Engine/Content/Asset.cpp b/Source/Engine/Content/Asset.cpp index da6b7329a..5560c20b9 100644 --- a/Source/Engine/Content/Asset.cpp +++ b/Source/Engine/Content/Asset.cpp @@ -272,6 +272,7 @@ String Asset::ToString() const void Asset::OnDeleteObject() { + PROFILE_CPU_NAMED("Asset.Unload"); ASSERT(IsInMainThread()); // Send event to the gameplay so it can release handle to this asset diff --git a/Source/Engine/Level/Prefabs/Prefab.cpp b/Source/Engine/Level/Prefabs/Prefab.cpp index d309bf7bb..80120ea87 100644 --- a/Source/Engine/Level/Prefabs/Prefab.cpp +++ b/Source/Engine/Level/Prefabs/Prefab.cpp @@ -7,6 +7,7 @@ #include "Engine/Core/Log.h" #include "Engine/Level/Prefabs/PrefabManager.h" #include "Engine/Level/Actor.h" +#include "Engine/Profiler/ProfilerCPU.h" #include "Engine/Threading/Threading.h" #include "Engine/Scripting/Scripting.h" @@ -22,6 +23,7 @@ Prefab::Prefab(const SpawnParams& params, const AssetInfo* info) Guid Prefab::GetRootObjectId() const { + PROFILE_CPU(); ASSERT(IsLoaded()); ScopeLock lock(Locker); @@ -57,6 +59,7 @@ Actor* Prefab::GetDefaultInstance() // Skip if already created (reuse cached result) if (_defaultInstance) return _defaultInstance; + PROFILE_CPU(); // Skip if not loaded if (!IsLoaded()) From 9ce602619269f8fe9bcacd746ce191fcd83bb8c9 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Tue, 2 Jun 2026 14:07:28 +0200 Subject: [PATCH 05/10] Optimize model actors entries serialization --- .../Graphics/Models/ModelInstanceEntry.cpp | 18 +++++++++++++----- .../Graphics/Models/ModelInstanceEntry.h | 1 + Source/Engine/Level/Actors/AnimatedModel.cpp | 7 ++----- Source/Engine/Level/Actors/SplineModel.cpp | 7 ++----- Source/Engine/Level/Actors/StaticModel.cpp | 6 ++---- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Source/Engine/Graphics/Models/ModelInstanceEntry.cpp b/Source/Engine/Graphics/Models/ModelInstanceEntry.cpp index 1c9440b39..6f8ace8d7 100644 --- a/Source/Engine/Graphics/Models/ModelInstanceEntry.cpp +++ b/Source/Engine/Graphics/Models/ModelInstanceEntry.cpp @@ -21,6 +21,13 @@ bool ModelInstanceEntries::HasContentLoaded() const return result; } +bool ModelInstanceEntries::ShouldSerialize(const void* otherObj) const +{ + if (!otherObj) + return true; + return !(*this == *(const ModelInstanceEntries*)otherObj); +} + void ModelInstanceEntries::Serialize(SerializeStream& stream, const void* otherObj) { SERIALIZE_GET_OTHER_OBJ(ModelInstanceEntries); @@ -43,12 +50,13 @@ void ModelInstanceEntries::Serialize(SerializeStream& stream, const void* otherO void ModelInstanceEntries::Deserialize(DeserializeStream& stream, ISerializeModifier* modifier) { PROFILE_MEM(Graphics); - const DeserializeStream& entries = stream["Entries"]; - ASSERT(entries.IsArray()); - Resize(entries.Size()); - for (rapidjson::SizeType i = 0; i < entries.Size(); i++) + const DeserializeStream& entriesData = stream[DeserializeStream::GenericValue(rapidjson::StringRef("Entries", 7))]; + CHECK(entriesData.IsArray()); + Resize(entriesData.Size()); + ModelInstanceEntry* entries = Get(); + for (int32 i = 0; i < Count(); i++) { - At(i).Deserialize((DeserializeStream&)entries[i], modifier); + entries[i].Deserialize((DeserializeStream&)entriesData[i], modifier); } } diff --git a/Source/Engine/Graphics/Models/ModelInstanceEntry.h b/Source/Engine/Graphics/Models/ModelInstanceEntry.h index 8f8a2ca3c..79403cce1 100644 --- a/Source/Engine/Graphics/Models/ModelInstanceEntry.h +++ b/Source/Engine/Graphics/Models/ModelInstanceEntry.h @@ -115,6 +115,7 @@ public: public: // [ISerializable] + bool ShouldSerialize(const void* otherObj) const override; void Serialize(SerializeStream& stream, const void* otherObj) override; void Deserialize(DeserializeStream& stream, ISerializeModifier* modifier) override; }; diff --git a/Source/Engine/Level/Actors/AnimatedModel.cpp b/Source/Engine/Level/Actors/AnimatedModel.cpp index bc3c5b8fb..ba3a2025d 100644 --- a/Source/Engine/Level/Actors/AnimatedModel.cpp +++ b/Source/Engine/Level/Actors/AnimatedModel.cpp @@ -1262,9 +1262,7 @@ void AnimatedModel::Serialize(SerializeStream& stream, const void* otherObj) SERIALIZE(ShadowsMode); PRAGMA_ENABLE_DEPRECATION_WARNINGS SERIALIZE(RootMotionTarget); - - stream.JKEY("Buffer"); - stream.Object(&Entries, other ? &other->Entries : nullptr); + SERIALIZE_MEMBER(Buffer, Entries); } void AnimatedModel::Deserialize(DeserializeStream& stream, ISerializeModifier* modifier) @@ -1289,8 +1287,7 @@ void AnimatedModel::Deserialize(DeserializeStream& stream, ISerializeModifier* m DESERIALIZE(ShadowsMode); PRAGMA_ENABLE_DEPRECATION_WARNINGS DESERIALIZE(RootMotionTarget); - - Entries.DeserializeIfExists(stream, "Buffer", modifier); + DESERIALIZE_MEMBER(Buffer, Entries); // [Deprecated on 07.02.2022, expires on 07.02.2024] if (modifier->EngineBuild <= 6330) diff --git a/Source/Engine/Level/Actors/SplineModel.cpp b/Source/Engine/Level/Actors/SplineModel.cpp index 0da4a3d70..fdda6941e 100644 --- a/Source/Engine/Level/Actors/SplineModel.cpp +++ b/Source/Engine/Level/Actors/SplineModel.cpp @@ -497,9 +497,7 @@ void SplineModel::Serialize(SerializeStream& stream, const void* otherObj) SERIALIZE_MEMBER(PreTransform, _preTransform) SERIALIZE(Model); SERIALIZE(DrawModes); - - stream.JKEY("Buffer"); - stream.Object(&Entries, other ? &other->Entries : nullptr); + SERIALIZE_MEMBER(Buffer, Entries); } void SplineModel::Deserialize(DeserializeStream& stream, ISerializeModifier* modifier) @@ -514,8 +512,7 @@ void SplineModel::Deserialize(DeserializeStream& stream, ISerializeModifier* mod DESERIALIZE_MEMBER(PreTransform, _preTransform); DESERIALIZE(Model); DESERIALIZE(DrawModes); - - Entries.DeserializeIfExists(stream, "Buffer", modifier); + DESERIALIZE_MEMBER(Buffer, Entries); // [Deprecated on 07.02.2022, expires on 07.02.2024] if (modifier->EngineBuild <= 6330) diff --git a/Source/Engine/Level/Actors/StaticModel.cpp b/Source/Engine/Level/Actors/StaticModel.cpp index 1fba1ba2e..82976b54c 100644 --- a/Source/Engine/Level/Actors/StaticModel.cpp +++ b/Source/Engine/Level/Actors/StaticModel.cpp @@ -464,8 +464,7 @@ void StaticModel::Serialize(SerializeStream& stream, const void* otherObj) stream.Rectangle(Lightmap.UVsArea); } - stream.JKEY("Buffer"); - stream.Object(&Entries, other ? &other->Entries : nullptr); + SERIALIZE_MEMBER(Buffer, Entries); if (_vertexColorsCount) { @@ -504,8 +503,7 @@ void StaticModel::Deserialize(DeserializeStream& stream, ISerializeModifier* mod DESERIALIZE_MEMBER(DrawModes, _drawModes); DESERIALIZE_MEMBER(LightmapIndex, Lightmap.TextureIndex); DESERIALIZE_MEMBER(LightmapArea, Lightmap.UVsArea); - - Entries.DeserializeIfExists(stream, "Buffer", modifier); + DESERIALIZE_MEMBER(Buffer, Entries); { const auto member = stream.FindMember("VertexColors"); From 2531a4b9182569a35decd0ce9c4ebd72c0b52361 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Tue, 2 Jun 2026 15:51:46 +0200 Subject: [PATCH 06/10] Fix asset 'Reload' option to be available when asset failed to load for manual load --- Source/Editor/Content/Items/AssetItem.cs | 4 ++-- Source/Editor/Windows/ContentWindow.ContextMenu.cs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Source/Editor/Content/Items/AssetItem.cs b/Source/Editor/Content/Items/AssetItem.cs index eaa93561a..78519b400 100644 --- a/Source/Editor/Content/Items/AssetItem.cs +++ b/Source/Editor/Content/Items/AssetItem.cs @@ -98,12 +98,12 @@ namespace FlaxEditor.Content } /// - /// Reloads the asset (if it's loaded). + /// Reloads the asset (if it's loaded or failed to load). /// public void Reload() { var asset = FlaxEngine.Content.GetAsset(ID); - if (asset != null && asset.IsLoaded) + if (asset != null && (asset.IsLoaded || asset.LastLoadFailed)) { asset.Reload(); } diff --git a/Source/Editor/Windows/ContentWindow.ContextMenu.cs b/Source/Editor/Windows/ContentWindow.ContextMenu.cs index f4985eb7a..22ef54bad 100644 --- a/Source/Editor/Windows/ContentWindow.ContextMenu.cs +++ b/Source/Editor/Windows/ContentWindow.ContextMenu.cs @@ -132,7 +132,8 @@ namespace FlaxEditor.Windows if (item is AssetItem assetItem) { - if (assetItem.IsLoaded) + var asset = FlaxEngine.Content.GetAsset(assetItem.ID); + if (asset != null && (asset.IsLoaded || asset.LastLoadFailed)) cm.AddButton("Reload", assetItem.Reload); cm.AddButton("Copy asset ID", () => Clipboard.Text = JsonSerializer.GetStringID(assetItem.ID)); cm.AddButton("Select actors using this asset", () => Editor.SceneEditing.SelectActorsUsingAsset(assetItem.ID)); From 44117084c887f474f9dd82de8fa2af6f838d3ffa Mon Sep 17 00:00:00 2001 From: Murry Lancashire Date: Tue, 2 Jun 2026 15:53:35 +0200 Subject: [PATCH 07/10] Fix asset cache eviction for locked files to retain cached info https://github.com/LOOPDISK/FlaxEngine/pull/45 --- Source/Engine/Content/Cache/AssetsCache.cpp | 126 ++++++++++++-------- Source/Engine/Content/Cache/AssetsCache.h | 30 ++++- 2 files changed, 105 insertions(+), 51 deletions(-) diff --git a/Source/Engine/Content/Cache/AssetsCache.cpp b/Source/Engine/Content/Cache/AssetsCache.cpp index 96083509e..e36a0d76c 100644 --- a/Source/Engine/Content/Cache/AssetsCache.cpp +++ b/Source/Engine/Content/Cache/AssetsCache.cpp @@ -113,7 +113,7 @@ void AssetsCache::Init() } // Use only valid entries - if (IsEntryValid(e)) + if (IsEntryValid(e) != EntryValidation::Invalid) _registry.Add(e.Info.ID, e); else rejectedCount++; @@ -295,14 +295,23 @@ bool AssetsCache::FindAsset(const StringView& path, AssetInfo& info) auto& e = i->Value; if (e.Info.Path == path) { - if (!IsEntryValid(e)) + const auto validation = IsEntryValid(e); + if (validation == EntryValidation::Invalid) { LOG(Warning, "Missing file from registry: \'{0}\':{1}:{2}", e.Info.Path, e.Info.ID, e.Info.TypeName); _registry.Remove(i); } else { - // Found +#if ENABLE_ASSETS_DISCOVERY + if (validation == EntryValidation::Inaccessible && !e.WarnedInaccessible) + { + e.WarnedInaccessible = true; + LOG(Warning, "Asset file locked, keeping cached entry: \'{0}\':{1}:{2}", e.Info.Path, e.Info.ID, e.Info.TypeName); + } +#endif + + // Found valid or inaccessible but return cached info either way result = true; info = e.Info; } @@ -322,13 +331,22 @@ bool AssetsCache::FindAsset(const Guid& id, AssetInfo& info) auto e = _registry.TryGet(id); if (e != nullptr) { - if (!IsEntryValid(*e)) + const auto validation = IsEntryValid(*e); + if (validation == EntryValidation::Invalid) { LOG(Warning, "Missing file from registry: \'{0}\':{1}:{2}", e->Info.Path, e->Info.ID, e->Info.TypeName); _registry.Remove(id); } else { +#if ENABLE_ASSETS_DISCOVERY + if (validation == EntryValidation::Inaccessible && !e->WarnedInaccessible) + { + e->WarnedInaccessible = true; + LOG(Warning, "Asset file locked, keeping cached entry: \'{0}\':{1}:{2}", e->Info.Path, e->Info.ID, e->Info.TypeName); + } +#endif + // Found result = true; info = e->Info; @@ -567,60 +585,70 @@ bool AssetsCache::RenameAsset(const StringView& oldPath, const StringView& newPa #endif -bool AssetsCache::IsEntryValid(Entry& e) +AssetsCache::EntryValidation AssetsCache::IsEntryValid(Entry& e) { #if ENABLE_ASSETS_DISCOVERY // Check if file exists - if (FileSystem::FileExists(e.Info.Path)) + if (!FileSystem::FileExists(e.Info.Path)) + return EntryValidation::Invalid; + + // Check if file hasn't been modified + const auto fileModified = FileSystem::GetFileLastEditTime(e.Info.Path); + if (fileModified == e.FileModified) { - // Check if file hasn't been modified - const auto fileModified = FileSystem::GetFileLastEditTime(e.Info.Path); - if (fileModified == e.FileModified) - return true; - - const auto extension = FileSystem::GetExtension(e.Info.Path).ToLower(); - - // Check if it's a binary asset - if (ContentStorageManager::IsFlaxStorageExtension(extension)) - { - // Validate ID within storage container - const auto storage = ContentStorageManager::GetStorage(e.Info.Path); - if (storage) - { - // Check if storage at given location contains that asset - const bool isValid = storage->HasAsset(e.Info); - - // Update entry and mark cache as dirty - e.FileModified = fileModified; - _isDirty = true; - - return isValid; - } - } - // Check for json resource - else if (JsonStorageProxy::IsValidExtension(extension)) - { - // Check Json storage layer - Guid jsonId; - String jsonTypeName; - if (JsonStorageProxy::GetAssetInfo(e.Info.Path, jsonId, jsonTypeName)) - { - const bool isValid = e.Info.ID == jsonId && e.Info.TypeName == jsonTypeName; - - // Update entry and mark cache as dirty - e.FileModified = fileModified; - _isDirty = true; - - return isValid; - } - } + e.WarnedInaccessible = false; + return EntryValidation::Valid; } - return false; + const auto extension = FileSystem::GetExtension(e.Info.Path).ToLower(); + // Check if it's a binary asset + if (ContentStorageManager::IsFlaxStorageExtension(extension)) + { + // Validate ID within storage container + const auto storage = ContentStorageManager::GetStorage(e.Info.Path); + if (storage) + { + // Check if storage at given location contains that asset + const bool isValid = storage->HasAsset(e.Info); + + // Update entry and mark cache as dirty + e.FileModified = fileModified; + e.WarnedInaccessible = false; + _isDirty = true; + + return isValid ? EntryValidation::Valid : EntryValidation::Invalid; + } + } + // Check for json resource + else if (JsonStorageProxy::IsValidExtension(extension)) + { + // Check Json storage layer + Guid jsonId; + String jsonTypeName; + if (JsonStorageProxy::GetAssetInfo(e.Info.Path, jsonId, jsonTypeName)) + { + const bool isValid = e.Info.ID == jsonId && e.Info.TypeName == jsonTypeName; + + // Update entry and mark cache as dirty + e.FileModified = fileModified; + e.WarnedInaccessible = false; + _isDirty = true; + + return isValid ? EntryValidation::Valid : EntryValidation::Invalid; + } + } + else + { + // Unknown file type + return EntryValidation::Invalid; + } + + // File exists but cannot be read (likely locked by git or another process) + return EntryValidation::Inaccessible; #else // In game we don't care about it because all cached asset entries are valid (precached) // Skip only entries with missing file - return e.Info.Path.HasChars(); + return e.Info.Path.HasChars() ? EntryValidation::Valid : EntryValidation::Invalid; #endif } diff --git a/Source/Engine/Content/Cache/AssetsCache.h b/Source/Engine/Content/Cache/AssetsCache.h index 42f05a2aa..28380b69d 100644 --- a/Source/Engine/Content/Cache/AssetsCache.h +++ b/Source/Engine/Content/Cache/AssetsCache.h @@ -58,6 +58,11 @@ public: /// The file modified date. /// DateTime FileModified; + + /// + /// True if a warning about this entry being inaccessible has already been logged (prevents log spam). Runtime-only, not serialized. + /// + bool WarnedInaccessible = false; #endif Entry() @@ -73,6 +78,27 @@ public: } }; + /// + /// Result of validating an asset cache entry. + /// + enum class EntryValidation + { + /// + /// File verified, contains this asset. + /// + Valid, + + /// + /// File missing or contains a different asset. + /// + Invalid, + + /// + /// File exists but cannot be opened (locked by another process). + /// + Inaccessible, + }; + typedef Dictionary Registry; typedef Dictionary PathsMapping; @@ -232,6 +258,6 @@ public: /// Determines whether cached asset entry is valid. /// /// The asset entry. - /// True if is valid, otherwise false. - bool IsEntryValid(Entry& e); + /// The validation result. + EntryValidation IsEntryValid(Entry& e); }; From 24654e5b02abb0cca7ca09babd9b4e5f841f6860 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Tue, 2 Jun 2026 15:54:00 +0200 Subject: [PATCH 08/10] Add `CHECK_NO_RETURN` for checks in code that should continue function execution --- Source/Engine/Content/Storage/FlaxStorage.cpp | 6 +++--- Source/Engine/Platform/Platform.h | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Source/Engine/Content/Storage/FlaxStorage.cpp b/Source/Engine/Content/Storage/FlaxStorage.cpp index 8dee911e4..4eaea48f8 100644 --- a/Source/Engine/Content/Storage/FlaxStorage.cpp +++ b/Source/Engine/Content/Storage/FlaxStorage.cpp @@ -243,9 +243,9 @@ FlaxStorage::~FlaxStorage() { // Validate if has been disposed ASSERT(IsDisposed()); - CHECK(_chunksLock == 0); - CHECK(_refCount == 0); - CHECK(_isUnloadingData == 0); + CHECK_NO_RETURN(_chunksLock == 0); + CHECK_NO_RETURN(_refCount == 0); + CHECK_NO_RETURN(_isUnloadingData == 0); ASSERT(_chunks.IsEmpty()); #if USE_EDITOR diff --git a/Source/Engine/Platform/Platform.h b/Source/Engine/Platform/Platform.h index e2c6808c9..438cb6602 100644 --- a/Source/Engine/Platform/Platform.h +++ b/Source/Engine/Platform/Platform.h @@ -75,6 +75,12 @@ Platform::CheckFailed(#expression, __FILE__, __LINE__); \ return returnValue; \ } +// Performs a soft check of the expression. Logs the expression failure and continues execution. +#define CHECK_NO_RETURN(expression) \ + if (!(expression)) \ + { \ + Platform::CheckFailed(#expression, __FILE__, __LINE__); \ + } #if ENABLE_ASSERTION // Performs a soft check of the expression. Logs the expression failure and returns from the function call. From 777602fee6ae6f6d5d41b2b188811677c5bc29b5 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Tue, 2 Jun 2026 15:54:36 +0200 Subject: [PATCH 09/10] Fix asset storage handling when file is locked https://github.com/LOOPDISK/FlaxEngine/pull/45 --- Source/Engine/Content/BinaryAsset.cpp | 5 +---- Source/Engine/Content/Cache/AssetsCache.cpp | 4 ++-- Source/Engine/Content/Storage/ContentStorageManager.cpp | 4 ++++ 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Source/Engine/Content/BinaryAsset.cpp b/Source/Engine/Content/BinaryAsset.cpp index 04fb3c3cf..c79e011b2 100644 --- a/Source/Engine/Content/BinaryAsset.cpp +++ b/Source/Engine/Content/BinaryAsset.cpp @@ -564,10 +564,7 @@ ContentLoadTask* BinaryAsset::createLoadingTask() loadTask = preLoadChunksTask; } - // Before asset loading we have to initialize storage - // TODO: maybe in build game we could do it in place? - // This step is only for opening asset files in background and upgrading them - // In build game we have only a few packages which are ready to use + // Before asset loading we have to initialize storage and pull the asset header auto initTask = New(this); initTask->ContinueWith(loadTask); loadTask = initTask; diff --git a/Source/Engine/Content/Cache/AssetsCache.cpp b/Source/Engine/Content/Cache/AssetsCache.cpp index e36a0d76c..96da061f1 100644 --- a/Source/Engine/Content/Cache/AssetsCache.cpp +++ b/Source/Engine/Content/Cache/AssetsCache.cpp @@ -378,13 +378,13 @@ void AssetsCache::GetAllByTypeName(const StringView& typeName, Array& resu void AssetsCache::RegisterAssets(FlaxStorage* storage) { PROFILE_CPU(); - ASSERT(storage); // Get all entries Array entries; storage->GetEntries(entries); - ASSERT(entries.HasItems()); + if (entries.IsEmpty()) + return; ASSETS_CACHE_LOCK(); auto storagePath = storage->GetPath(); diff --git a/Source/Engine/Content/Storage/ContentStorageManager.cpp b/Source/Engine/Content/Storage/ContentStorageManager.cpp index 8aa36c7be..33a16e031 100644 --- a/Source/Engine/Content/Storage/ContentStorageManager.cpp +++ b/Source/Engine/Content/Storage/ContentStorageManager.cpp @@ -54,6 +54,7 @@ FlaxStorageReference ContentStorageManager::GetStorage(const StringView& path, b Locker.Lock(); // Try fast lookup + bool wasCached = true; FlaxStorage* storage; if (!StorageMap.TryGet(path, storage)) { @@ -74,6 +75,7 @@ FlaxStorageReference ContentStorageManager::GetStorage(const StringView& path, b // Register storage container StorageMap.Add(path, storage); + wasCached = false; } // Build reference (before releasing the lock so ContentStorageSystem::Job won't delete it when running from async thread) @@ -90,6 +92,8 @@ FlaxStorageReference ContentStorageManager::GetStorage(const StringView& path, b if (loadFailed) { LOG(Error, "Failed to load {0}.", path); + if (wasCached) + return result; Locker.Lock(); StorageMap.Remove(path); if (storage->IsPackage()) From 84ccb9df0c72a569d77609a6e9973271743f7079 Mon Sep 17 00:00:00 2001 From: Murry Lancashire Date: Tue, 2 Jun 2026 16:42:30 +0200 Subject: [PATCH 10/10] Add more LOD Generation options to model import settings (eg. borders lock, preserve UVs) https://github.com/LOOPDISK/FlaxEngine/commit/1cfd4634727055c2a7249dff447528877332beab --- .../Editor/Content/Import/ModelImportEntry.cs | 1 + Source/Engine/Tools/ModelTool/ModelTool.cpp | 55 +++++++++++++++++-- Source/Engine/Tools/ModelTool/ModelTool.h | 22 ++++++-- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/Source/Editor/Content/Import/ModelImportEntry.cs b/Source/Editor/Content/Import/ModelImportEntry.cs index 5e17d7ecf..eed8db6ba 100644 --- a/Source/Editor/Content/Import/ModelImportEntry.cs +++ b/Source/Editor/Content/Import/ModelImportEntry.cs @@ -19,6 +19,7 @@ namespace FlaxEngine.Tools private bool ShowRootMotion => ShowAnimation && RootMotion != RootMotionMode.None; private bool ShowSmoothingNormalsAngle => ShowGeometry && CalculateNormals; private bool ShowSmoothingTangentsAngle => ShowGeometry && CalculateTangents; + private bool ShowGenerateLODs => ShowGeometry && GenerateLODs; private bool ShowFramesRange => ShowAnimation && Duration == AnimationDuration.Custom; private bool ShowSplitting => Type != ModelType.Prefab; } diff --git a/Source/Engine/Tools/ModelTool/ModelTool.cpp b/Source/Engine/Tools/ModelTool/ModelTool.cpp index 119fa9555..5fe834ad0 100644 --- a/Source/Engine/Tools/ModelTool/ModelTool.cpp +++ b/Source/Engine/Tools/ModelTool/ModelTool.cpp @@ -588,6 +588,10 @@ void ModelTool::Options::Serialize(SerializeStream& stream, const void* otherObj SERIALIZE(TriangleReduction); SERIALIZE(SloppyOptimization); SERIALIZE(LODTargetError); + SERIALIZE(LODTargetErrorAbsolute); + SERIALIZE(LODLockBorder); + SERIALIZE(LODPreserveUVs); + SERIALIZE(LODPreserveUVsWeight); SERIALIZE(ImportMaterials); SERIALIZE(CreateEmptyMaterialSlots); SERIALIZE(ImportMaterialsAsInstances); @@ -645,6 +649,10 @@ void ModelTool::Options::Deserialize(DeserializeStream& stream, ISerializeModifi DESERIALIZE(TriangleReduction); DESERIALIZE(SloppyOptimization); DESERIALIZE(LODTargetError); + DESERIALIZE(LODTargetErrorAbsolute); + DESERIALIZE(LODLockBorder); + DESERIALIZE(LODPreserveUVs); + DESERIALIZE(LODPreserveUVsWeight); DESERIALIZE(ImportMaterials); DESERIALIZE(CreateEmptyMaterialSlots); DESERIALIZE(ImportMaterialsAsInstances); @@ -1954,6 +1962,7 @@ bool ModelTool::ImportModel(const String& path, ModelData& data, Options& option // Automatic LOD generation if (options.GenerateLODs && options.LODCount > 1 && data.LODs.HasItems() && options.TriangleReduction < 1.0f - ZeroTolerance) { + PROFILE_CPU_NAMED("GenerateLODs"); auto lodStartTime = DateTime::NowUTC(); meshopt_setAllocator(MeshOptAllocate, MeshOptDeallocate); float triangleReduction = Math::Saturate(options.TriangleReduction); @@ -1992,13 +2001,51 @@ bool ModelTool::ImportModel(const String& path, ModelData& data, Options& option continue; indices.Clear(); indices.Resize(srcMeshIndexCount); - int32 dstMeshIndexCount = {}; + int32 dstMeshIndexCount = 0; if (options.SloppyOptimization) + { + PROFILE_CPU_NAMED("meshopt_simplifySloppy"); dstMeshIndexCount = (int32)meshopt_simplifySloppy(indices.Get(), srcMesh->Indices.Get(), srcMeshIndexCount, (const float*)srcMesh->Positions.Get(), srcMeshVertexCount, sizeof(Float3), dstMeshIndexCountTarget, options.LODTargetError); + } else - dstMeshIndexCount = (int32)meshopt_simplify(indices.Get(), srcMesh->Indices.Get(), srcMeshIndexCount, (const float*)srcMesh->Positions.Get(), srcMeshVertexCount, sizeof(Float3), dstMeshIndexCountTarget, options.LODTargetError); - if (dstMeshIndexCount <= 0 || dstMeshIndexCount > indices.Count()) - continue; + { + // Build simplification flags + unsigned int simplifyOptions = 0; + if (options.LODLockBorder) + simplifyOptions |= meshopt_SimplifyLockBorder; + if (options.LODTargetErrorAbsolute) + simplifyOptions |= meshopt_SimplifyErrorAbsolute; + if (options.LODPreserveUVs && srcMesh->UVs.HasItems()) + { + // Pack UV channels as attributes for meshopt_simplifyWithAttributes + int32 uvChannelCount = srcMesh->UVs.Count(); + int32 attributeCount = uvChannelCount * 2; // 2 floats (U, V) per channel + Array attributes; + attributes.Resize(srcMeshVertexCount * attributeCount); + Array attributeWeights; + attributeWeights.Resize(attributeCount); + for (int32 ch = 0; ch < uvChannelCount; ch++) + { + for (int32 v = 0; v < srcMeshVertexCount; v++) + { + Float2 uv = srcMesh->UVs[ch][v]; + attributes[v * attributeCount + ch * 2 + 0] = uv.X; + attributes[v * attributeCount + ch * 2 + 1] = uv.Y; + } + attributeWeights[ch * 2 + 0] = options.LODPreserveUVsWeight; + attributeWeights[ch * 2 + 1] = options.LODPreserveUVsWeight; + } + PROFILE_CPU_NAMED("meshopt_simplifyWithAttributes"); + dstMeshIndexCount = (int32)meshopt_simplifyWithAttributes(indices.Get(), srcMesh->Indices.Get(), srcMeshIndexCount, (const float*)srcMesh->Positions.Get(), srcMeshVertexCount, sizeof(Float3), attributes.Get(), sizeof(float) * attributeCount, attributeWeights.Get(), attributeCount, nullptr, dstMeshIndexCountTarget, options.LODTargetError, simplifyOptions, nullptr); + } + else + { + PROFILE_CPU_NAMED("meshopt_simplify"); + dstMeshIndexCount = (int32)meshopt_simplify(indices.Get(), srcMesh->Indices.Get(), srcMeshIndexCount, (const float*)srcMesh->Positions.Get(), srcMeshVertexCount, sizeof(Float3), dstMeshIndexCountTarget, options.LODTargetError, simplifyOptions, nullptr); + } + } + if (dstMeshIndexCount <= 0 || dstMeshIndexCount >= indices.Count()) + continue; // Skip if failed to generate LOD or it doesn't have less vertices than source indices.Resize(dstMeshIndexCount); // Generate simplified vertex buffer remapping table (use only vertices from LOD index buffer) diff --git a/Source/Engine/Tools/ModelTool/ModelTool.h b/Source/Engine/Tools/ModelTool/ModelTool.h index ff47e635d..f104bd83f 100644 --- a/Source/Engine/Tools/ModelTool/ModelTool.h +++ b/Source/Engine/Tools/ModelTool/ModelTool.h @@ -296,20 +296,32 @@ public: API_FIELD(Attributes="EditorOrder(1100), EditorDisplay(\"Level Of Detail\", \"Generate LODs\"), VisibleIf(nameof(ShowGeometry))") bool GenerateLODs = false; // The index of the LOD from the source model data to use as a reference for following LODs generation. - API_FIELD(Attributes="EditorOrder(1110), EditorDisplay(\"Level Of Detail\", \"Base LOD\"), VisibleIf(nameof(ShowGeometry)), Limit(0, 5, 0.065f)") + API_FIELD(Attributes="EditorOrder(1110), EditorDisplay(\"Level Of Detail\", \"Base LOD\"), VisibleIf(nameof(ShowGenerateLODs)), Limit(0, 5, 0.065f)") int32 BaseLOD = 0; // The amount of LODs to include in the model (all remaining ones starting from Base LOD will be generated). - API_FIELD(Attributes="EditorOrder(1120), EditorDisplay(\"Level Of Detail\", \"LOD Count\"), VisibleIf(nameof(ShowGeometry)), Limit(1, 6, 0.065f)") + API_FIELD(Attributes="EditorOrder(1120), EditorDisplay(\"Level Of Detail\", \"LOD Count\"), VisibleIf(nameof(ShowGenerateLODs)), Limit(1, 6, 0.065f)") int32 LODCount = 4; // The target amount of triangles for the generated LOD (based on the higher LOD). Normalized to range 0-1. For instance 0.4 cuts the triangle count to 40%. - API_FIELD(Attributes="EditorOrder(1130), EditorDisplay(\"Level Of Detail\"), VisibleIf(nameof(ShowGeometry)), Limit(0, 1, 0.001f)") + API_FIELD(Attributes="EditorOrder(1130), EditorDisplay(\"Level Of Detail\"), VisibleIf(nameof(ShowGenerateLODs)), Limit(0, 1, 0.001f)") float TriangleReduction = 0.5f; // Whether to do a sloppy mesh optimization. This is faster but does not follow the topology of the original mesh. - API_FIELD(Attributes="EditorOrder(1140), EditorDisplay(\"Level Of Detail\"), VisibleIf(nameof(ShowGeometry))") + API_FIELD(Attributes="EditorOrder(1140), EditorDisplay(\"Level Of Detail\"), VisibleIf(nameof(ShowGenerateLODs))") bool SloppyOptimization = false; // Target error is an approximate measure of the deviation from the original mesh using distance normalized to [0,1] range (e.g. 0.01 means that simplifier will try to maintain the error to be below 1% of the mesh extents). Only used if Sloppy is unchecked. - API_FIELD(Attributes="EditorOrder(1150), EditorDisplay(\"Level Of Detail\"), VisibleIf(nameof(SloppyOptimization), true), VisibleIf(nameof(ShowGeometry)), Limit(0.01f, 1, 0.001f)") + API_FIELD(Attributes="EditorOrder(1150), EditorDisplay(\"Level Of Detail\", \"LOD Target Error\"), VisibleIf(nameof(SloppyOptimization), true), VisibleIf(nameof(ShowGenerateLODs)), Limit(0.01f, 1, 0.001f)") float LODTargetError = 0.05f; + // If checked, vertices on topological borders (edges without a paired triangle) will not be moved during simplification. Useful for meshes that tile or share edges with other meshes. + API_FIELD(Attributes="EditorOrder(1170), EditorDisplay(\"Level Of Detail\", \"Lock Border\"), VisibleIf(nameof(SloppyOptimization), true), VisibleIf(nameof(ShowGenerateLODs))") + bool LODLockBorder = false; + // If checked, the target error will be treated as absolute rather than relative to the mesh extents. In that mode, error is defined in absolute units which can be universal across similar mesh types no matter their size. + API_FIELD(Attributes="EditorOrder(1160), EditorDisplay(\"Level Of Detail\", \"LOD Target Error Absolute\"), VisibleIf(nameof(SloppyOptimization), true), VisibleIf(nameof(ShowGenerateLODs))") + bool LODTargetErrorAbsolute = false; + // If checked, UV channels will be included in the simplification error metric to preserve UV layout. Essential for trimsheets and atlased textures. + API_FIELD(Attributes="EditorOrder(1180), EditorDisplay(\"Level Of Detail\", \"Preserve UVs\"), VisibleIf(nameof(SloppyOptimization), true), VisibleIf(nameof(ShowGenerateLODs))") + bool LODPreserveUVs = false; + // The weight of UV attributes in the simplification error metric. Higher values preserve UVs more aggressively at the cost of geometric quality. Only used when Preserve UVs is enabled. + API_FIELD(Attributes="EditorOrder(1190), EditorDisplay(\"Level Of Detail\", \"Preserve UVs Weight\"), VisibleIf(nameof(LODPreserveUVs)), VisibleIf(nameof(SloppyOptimization), true), VisibleIf(nameof(ShowGenerateLODs)), Limit(0.001f, 1, 0.001f)") + float LODPreserveUVsWeight = 0.01f; public: // Materials