From de30ee96b10050406bc98121028f43a6b804914e Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 22:46:50 +0800 Subject: [PATCH 1/3] Refine editor asset and viewport interactions --- Engine/Source/UI/EditorWidgets.cpp | 15 + Engine/Source/UI/EditorWidgets.h | 6 + Engine/Source/UI/Panels/AssetsPanel.cpp | 628 ++++++++++++++++------ Engine/Source/UI/Panels/AssetsPanel.h | 7 + Engine/Source/UI/Panels/ViewportPanel.cpp | 244 +++++++-- Tests/editor_domain_target_contracts.py | 55 ++ 6 files changed, 750 insertions(+), 205 deletions(-) diff --git a/Engine/Source/UI/EditorWidgets.cpp b/Engine/Source/UI/EditorWidgets.cpp index f2094f1..b6b7188 100644 --- a/Engine/Source/UI/EditorWidgets.cpp +++ b/Engine/Source/UI/EditorWidgets.cpp @@ -775,6 +775,21 @@ void EditorWidgets::Icon(EEditorIcon EditorIcon, EEditorColor Color, float Size) FEditorStyle::ColorU32(Color)); } +void EditorWidgets::IconAt( + ImDrawList* DrawList, + const ImVec2& Position, + EEditorIcon EditorIcon, + EEditorColor Color, + float Size) +{ + DrawEditorIcon( + DrawList, + EditorIcon, + Position, + FEditorStyle::Scale(Size), + FEditorStyle::ColorU32(Color)); +} + bool EditorWidgets::Button( const char* Id, EEditorIcon ButtonIcon, diff --git a/Engine/Source/UI/EditorWidgets.h b/Engine/Source/UI/EditorWidgets.h index 94896b2..e267cc9 100644 --- a/Engine/Source/UI/EditorWidgets.h +++ b/Engine/Source/UI/EditorWidgets.h @@ -92,6 +92,12 @@ namespace EditorWidgets EEditorIcon Icon, EEditorColor Color = EEditorColor::TextMuted, float Size = 16.0f); + void IconAt( + ImDrawList* DrawList, + const ImVec2& Position, + EEditorIcon Icon, + EEditorColor Color = EEditorColor::TextMuted, + float Size = 16.0f); // Vector-icon buttons keep the same geometry for hover, pressed, disabled // and keyboard-focus states. Id is never rendered and must be stable. diff --git a/Engine/Source/UI/Panels/AssetsPanel.cpp b/Engine/Source/UI/Panels/AssetsPanel.cpp index baa5915..86d25b1 100644 --- a/Engine/Source/UI/Panels/AssetsPanel.cpp +++ b/Engine/Source/UI/Panels/AssetsPanel.cpp @@ -48,6 +48,31 @@ namespace } return Label; } + + std::string GetAssetLocation(const std::string& RelativePath) + { + const std::filesystem::path Parent = + std::filesystem::path(RelativePath).parent_path(); + return Parent.empty() + ? "Assets" + : "Assets/" + Parent.generic_string(); + } + + std::string GetAssetExtensionLabel(const std::string& FileName) + { + std::string Extension = + std::filesystem::path(FileName).extension().string(); + if (!Extension.empty() && Extension.front() == '.') + { + Extension.erase(Extension.begin()); + } + for (char& Character : Extension) + { + Character = static_cast( + std::toupper(static_cast(Character))); + } + return Extension.empty() ? "FILE" : Extension; + } } FAssetsPanel::FAssetsPanel(FUiContext& InContext) @@ -65,11 +90,6 @@ void FAssetsPanel::Draw(bool* bOpen) return; } - EditorWidgets::PanelHeader( - "Content Browser", - nullptr, - nullptr, - EEditorIcon::Folder); DrawToolbar(); DrawImportStatus(); @@ -86,7 +106,12 @@ void FAssetsPanel::Draw(bool* bOpen) bLastActionError ? EEditorBadge::Error : EEditorBadge::Success); } - ImGui::Dummy(ImVec2(0.0f, FEditorStyle::Scale(4.0f))); + if (GetSelectedAsset()) + { + DrawSelectionBar(); + ImGui::Dummy(ImVec2(0.0f, FEditorStyle::Scale(3.0f))); + } + ImGui::Dummy(ImVec2(0.0f, FEditorStyle::Scale(3.0f))); if (AssetEntries.empty() && bAssetScanInProgress) { EditorWidgets::EmptyState( @@ -96,13 +121,30 @@ void FAssetsPanel::Draw(bool* bOpen) ImGui::End(); return; } - if (bGridView) + + const float ResultsHeight = std::max(1.0f, ImGui::GetContentRegionAvail().y); + if (ImGui::BeginChild( + "AssetResults", + ImVec2(0.0f, ResultsHeight), + false, + ImGuiWindowFlags_NoScrollbar)) { - DrawAssetGrid(); + if (bGridView) + { + DrawAssetGrid(); + } + else + { + DrawAssetList(); + } } - else + ImGui::EndChild(); + + if (!ImGui::GetIO().WantTextInput + && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) + && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { - DrawAssetList(); + SelectedAssetPath.clear(); } ImGui::End(); @@ -159,7 +201,18 @@ void FAssetsPanel::PollAssetIndex() if (Result.bSucceeded) { AssetEntries = std::move(Result.Entries); + ModelAssetCount = 0; + TextureAssetCount = 0; + for (const FAssetEntry& Asset : AssetEntries) + { + ModelAssetCount += Asset.Type == EAssetType::Model ? 1 : 0; + TextureAssetCount += Asset.Type == EAssetType::Texture ? 1 : 0; + } bVisibleAssetsDirty = true; + if (!SelectedAssetPath.empty() && !GetSelectedAsset()) + { + SelectedAssetPath.clear(); + } } IndexWarning = std::move(Result.Warning); bAssetScanInProgress = false; @@ -283,21 +336,38 @@ void FAssetsPanel::DrawToolbar() const float UiScale = std::max(Context.UiScale, 0.5f); const float AvailableWidth = ImGui::GetContentRegionAvail().x; const float ClearWidth = FEditorStyle::Scale(28.0f); - const float RefreshWidth = FEditorStyle::Scale(104.0f); + const float ActionWidth = FEditorStyle::Scale(28.0f); + const float ActionGap = FEditorStyle::Scale(3.0f); + const float ActionsWidth = ClearWidth + ActionWidth * 3.0f + ActionGap * 3.0f; const float SearchWidth = std::max( 120.0f * UiScale, - AvailableWidth - ClearWidth - RefreshWidth - ImGui::GetStyle().ItemSpacing.x * 2.0f); + AvailableWidth - ActionsWidth - ImGui::GetStyle().ItemSpacing.x); + const ImGuiIO& IO = ImGui::GetIO(); + if (!IO.WantTextInput + && IO.KeyCtrl + && !IO.KeyShift + && !IO.KeyAlt + && !IO.KeySuper + && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) + && ImGui::IsKeyPressed(ImGuiKey_F, false)) + { + ImGui::SetKeyboardFocusHere(); + } if (EditorWidgets::SearchInput( "##asset_search", SearchBuffer, sizeof(SearchBuffer), - "Search assets and paths...", - ImGuiInputTextFlags_None, + "Search name, type, or path (Ctrl+F)", + ImGuiInputTextFlags_EscapeClearsAll, SearchWidth)) { SearchTextLower = ToLowerCopy(SearchBuffer); bVisibleAssetsDirty = true; } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Search asset names, formats, and relative paths"); + } ImGui::SameLine(); ImGui::BeginDisabled(SearchBuffer[0] == '\0'); if (EditorWidgets::IconButton( @@ -312,20 +382,47 @@ void FAssetsPanel::DrawToolbar() bVisibleAssetsDirty = true; } ImGui::EndDisabled(); - ImGui::SameLine(); + ImGui::SameLine(0.0f, ActionGap); ImGui::BeginDisabled(bAssetScanInProgress); - if (EditorWidgets::Button( + if (EditorWidgets::IconButton( "##RefreshAssets", EEditorIcon::Refresh, - bAssetScanInProgress ? "Indexing" : "Refresh", - EEditorButtonStyle::Default, - ImVec2(RefreshWidth, 0.0f))) + bAssetScanInProgress ? "Indexing project assets..." : "Refresh asset index", + false, + ImVec2(ActionWidth, 0.0f))) { RefreshAssetIndex(); } ImGui::EndDisabled(); + ImGui::SameLine(0.0f, ActionGap); + if (EditorWidgets::IconButton( + "##AssetListView", + EEditorIcon::List, + "List view", + !bGridView, + ImVec2(ActionWidth, 0.0f))) + { + bGridView = false; + } + ImGui::SameLine(0.0f, ActionGap); + if (EditorWidgets::IconButton( + "##AssetGridView", + EEditorIcon::Grid, + "Grid view", + bGridView, + ImVec2(ActionWidth, 0.0f))) + { + bGridView = true; + } + + const std::string AllLabel = "All " + std::to_string(AssetEntries.size()); + const std::string ModelsLabel = "Models " + std::to_string(ModelAssetCount); + const std::string TexturesLabel = "Textures " + std::to_string(TextureAssetCount); - if (EditorWidgets::FilterChip("##AssetAll", "All", ActiveFilter == EAssetFilter::All)) + if (EditorWidgets::FilterChip( + "##AssetAll", + AllLabel.c_str(), + ActiveFilter == EAssetFilter::All)) { ActiveFilter = EAssetFilter::All; bVisibleAssetsDirty = true; @@ -333,7 +430,7 @@ void FAssetsPanel::DrawToolbar() ImGui::SameLine(0.0f, FEditorStyle::Scale(3.0f)); if (EditorWidgets::FilterChip( "##AssetModels", - "Models", + ModelsLabel.c_str(), ActiveFilter == EAssetFilter::Models, EEditorIcon::Cube)) { @@ -343,7 +440,7 @@ void FAssetsPanel::DrawToolbar() ImGui::SameLine(0.0f, FEditorStyle::Scale(3.0f)); if (EditorWidgets::FilterChip( "##AssetTextures", - "Textures", + TexturesLabel.c_str(), ActiveFilter == EAssetFilter::Textures, EEditorIcon::Image)) { @@ -351,9 +448,14 @@ void FAssetsPanel::DrawToolbar() bVisibleAssetsDirty = true; } - const float ViewButtonsWidth = FEditorStyle::Scale(60.0f); - const float RightAlignedX = ImGui::GetWindowContentRegionMax().x - ViewButtonsWidth; - if (RightAlignedX > ImGui::GetCursorPosX()) + const size_t VisibleCount = GetVisibleAssetIndices().size(); + const std::string ResultSummary = + std::to_string(VisibleCount) + " shown" + + (bAssetScanInProgress ? " / indexing..." : ""); + const float ResultWidth = ImGui::CalcTextSize(ResultSummary.c_str()).x; + const float RightAlignedX = + ImGui::GetWindowContentRegionMax().x - ResultWidth; + if (RightAlignedX > ImGui::GetCursorPosX() + ImGui::GetStyle().ItemSpacing.x) { ImGui::SameLine(); ImGui::SetCursorPosX(RightAlignedX); @@ -362,31 +464,16 @@ void FAssetsPanel::DrawToolbar() { ImGui::Spacing(); } - if (EditorWidgets::IconButton( - "##AssetListView", - EEditorIcon::List, - "List view", - !bGridView)) - { - bGridView = false; - } - ImGui::SameLine(0.0f, FEditorStyle::Scale(2.0f)); - if (EditorWidgets::IconButton( - "##AssetGridView", - EEditorIcon::Grid, - "Grid view", - bGridView)) - { - bGridView = true; - } - - const size_t VisibleCount = GetVisibleAssetIndices().size(); ImGui::TextColored( FEditorStyle::Color(EEditorColor::TextMuted), - "%zu visible / %zu indexed%s", - VisibleCount, - AssetEntries.size(), - bAssetScanInProgress ? " / indexing" : ""); + "%s", + ResultSummary.c_str()); + + const FAssetEntry* SelectedAsset = GetSelectedAsset(); + if (SelectedAsset && !MatchesCurrentFilter(*SelectedAsset)) + { + SelectedAssetPath.clear(); + } } void FAssetsPanel::DrawImportStatus() @@ -459,22 +546,25 @@ void FAssetsPanel::DrawAssetList() const ImGuiTableFlags Flags = ImGuiTableFlags_Resizable | ImGuiTableFlags_RowBg - | ImGuiTableFlags_BordersInnerV + | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; const float TableHeight = std::max(1.0f, ImGui::GetContentRegionAvail().y); - if (!ImGui::BeginTable("AssetList", 4, Flags, ImVec2(0.0f, TableHeight))) + if (!ImGui::BeginTable("AssetList", 3, Flags, ImVec2(0.0f, TableHeight))) { return; } ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.30f); - ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 125.0f * Context.UiScale); - ImGui::TableSetupColumn("Relative path", ImGuiTableColumnFlags_WidthStretch, 0.55f); - ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, 95.0f * Context.UiScale); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.42f); + ImGui::TableSetupColumn("Location", ImGuiTableColumnFlags_WidthStretch, 0.43f); + ImGui::TableSetupColumn( + "Type", + ImGuiTableColumnFlags_WidthFixed, + 132.0f * Context.UiScale); ImGui::TableHeadersRow(); + const float RowHeight = FEditorStyle::Scale(34.0f); ImGuiListClipper Clipper; Clipper.Begin(static_cast(VisibleIndices.size())); while (Clipper.Step()) @@ -483,66 +573,95 @@ void FAssetsPanel::DrawAssetList() { const FAssetEntry& Asset = AssetEntries[VisibleIndices[static_cast(VisibleIndex)]]; ImGui::PushID(Asset.AbsolutePath.c_str()); - ImGui::TableNextRow(); + ImGui::TableNextRow(ImGuiTableRowFlags_None, RowHeight); ImGui::TableSetColumnIndex(0); + const bool bSelected = SelectedAssetPath == Asset.AbsolutePath; const EEditorIcon AssetIcon = Asset.Type == EAssetType::Model ? EEditorIcon::Cube : (Asset.Type == EAssetType::Texture ? EEditorIcon::Image : EEditorIcon::Folder); - EditorWidgets::Icon( + ImGui::PushStyleVar(ImGuiStyleVar_SelectableTextAlign, ImVec2(0.0f, 0.5f)); + const bool bActivated = ImGui::Selectable( + "##AssetRow", + bSelected, + ImGuiSelectableFlags_SpanAllColumns + | ImGuiSelectableFlags_AllowDoubleClick, + ImVec2(0.0f, RowHeight)); + ImGui::PopStyleVar(); + const bool bHovered = ImGui::IsItemHovered(); + const ImVec2 RowMin = ImGui::GetItemRectMin(); + ImDrawList* DrawList = ImGui::GetWindowDrawList(); + const ImVec4 NameClip( + RowMin.x, + RowMin.y, + ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x, + RowMin.y + RowHeight); + EditorWidgets::IconAt( + DrawList, + ImVec2( + RowMin.x + FEditorStyle::Scale(8.0f), + RowMin.y + (RowHeight - FEditorStyle::Scale(15.0f)) * 0.5f), AssetIcon, - Asset.Type == EAssetType::Model ? EEditorColor::AccentHovered : EEditorColor::TextMuted, + Asset.Type == EAssetType::Model + ? EEditorColor::AccentHovered + : EEditorColor::TextMuted, 15.0f); - ImGui::SameLine(0.0f, FEditorStyle::Scale(7.0f)); - bool bActivated = false; - if (Asset.Type == EAssetType::Model) + DrawList->AddText( + FEditorStyle::Font(bSelected ? EEditorFont::Medium : EEditorFont::Regular), + ImGui::GetFontSize(), + ImVec2( + RowMin.x + FEditorStyle::Scale(30.0f), + RowMin.y + (RowHeight - ImGui::GetTextLineHeight()) * 0.5f), + FEditorStyle::ColorU32(EEditorColor::Text), + Asset.FileName.c_str(), + nullptr, + 0.0f, + &NameClip); + if (bActivated) { - bActivated = ImGui::Selectable( - Asset.FileName.c_str(), - false, - ImGuiSelectableFlags_AllowDoubleClick); + SelectedAssetPath = Asset.AbsolutePath; } - else + if (bHovered) { - ImGui::TextUnformatted(Asset.FileName.c_str()); - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", Asset.AbsolutePath.c_str()); + ImGui::SetTooltip( + "%s\n%s", + Asset.AbsolutePath.c_str(), + Asset.Type == EAssetType::Model + ? "Double-click to add to the scene, or drag to the viewport." + : "Select to inspect the asset path."); } + DrawAssetContextMenu(Asset); BeginAssetDragSource(Asset); - if (bActivated && Asset.Type == EAssetType::Model + if (bActivated + && bHovered + && Asset.Type == EAssetType::Model && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { SpawnModelAsset(Asset); } ImGui::TableSetColumnIndex(1); - ImGui::TextColored( - FEditorStyle::Color(EEditorColor::TextMuted), - "%s", - Asset.TypeLabel.c_str()); + const ImVec2 LocationPosition = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddText( + FEditorStyle::Font(EEditorFont::Monospace), + ImGui::GetFontSize(), + ImVec2( + LocationPosition.x, + RowMin.y + (RowHeight - ImGui::GetTextLineHeight()) * 0.5f), + FEditorStyle::ColorU32(EEditorColor::TextMuted), + GetAssetLocation(Asset.RelativePath).c_str()); ImGui::TableSetColumnIndex(2); - ImGui::PushFont(FEditorStyle::Font(EEditorFont::Monospace), 0.0f); - ImGui::TextUnformatted(Asset.RelativePath.c_str()); - ImGui::PopFont(); - ImGui::TableSetColumnIndex(3); - if (Asset.Type == EAssetType::Model) - { - if (EditorWidgets::Button( - "##AddAssetToScene", - EEditorIcon::Plus, - "Add", - EEditorButtonStyle::Subtle, - ImVec2(ImGui::GetContentRegionAvail().x, 0.0f), - "Add model to the scene")) - { - SpawnModelAsset(Asset); - } - } - else - { - ImGui::TextDisabled("-"); - } + const ImVec2 TypePosition = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddText( + FEditorStyle::Font(EEditorFont::Medium), + ImGui::GetFontSize(), + ImVec2( + TypePosition.x, + RowMin.y + (RowHeight - ImGui::GetTextLineHeight()) * 0.5f), + FEditorStyle::ColorU32( + Asset.Type == EAssetType::Model + ? EEditorColor::AccentHovered + : EEditorColor::TextMuted), + Asset.TypeLabel.c_str()); ImGui::PopID(); } } @@ -563,11 +682,11 @@ void FAssetsPanel::DrawAssetGrid() } const float UiScale = std::max(Context.UiScale, 0.5f); - const float DesiredCardWidth = 210.0f * UiScale; + const float DesiredCardWidth = 176.0f * UiScale; const float AvailableWidth = std::max(ImGui::GetContentRegionAvail().x, DesiredCardWidth); const int ColumnCount = std::clamp( static_cast(AvailableWidth / DesiredCardWidth), 1, 32); - const float CardHeight = 176.0f * UiScale; + const float CardHeight = 112.0f * UiScale; const ImGuiTableFlags Flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_PadOuterX; @@ -584,77 +703,260 @@ void FAssetsPanel::DrawAssetGrid() if ((VisibleIndex % ColumnCount) == 0) { - ImGui::TableNextRow(ImGuiTableRowFlags_None, CardHeight + ImGui::GetStyle().ItemSpacing.y); + ImGui::TableNextRow( + ImGuiTableRowFlags_None, + CardHeight + ImGui::GetStyle().ItemSpacing.y); } ImGui::TableSetColumnIndex(VisibleIndex % ColumnCount); ++VisibleIndex; ImGui::PushID(Asset.AbsolutePath.c_str()); - const bool bChildVisible = ImGui::BeginChild( - "AssetCard", ImVec2(0.0f, CardHeight), true, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - if (bChildVisible) + const float CardWidth = std::max(ImGui::GetContentRegionAvail().x, 1.0f); + const ImVec2 CardMin = ImGui::GetCursorScreenPos(); + const bool bActivated = ImGui::InvisibleButton( + "##AssetCard", + ImVec2(CardWidth, CardHeight), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_EnableNav); + const bool bHovered = ImGui::IsItemHovered(); + const bool bHeld = ImGui::IsItemActive(); + const bool bFocused = ImGui::IsItemFocused(); + const bool bSelected = SelectedAssetPath == Asset.AbsolutePath; + if (bActivated) { - const EEditorIcon AssetIcon = Asset.Type == EAssetType::Model - ? EEditorIcon::Cube - : (Asset.Type == EAssetType::Texture ? EEditorIcon::Image : EEditorIcon::Folder); - const float PreviewSize = FEditorStyle::Scale(34.0f); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() - + std::max((ImGui::GetContentRegionAvail().x - PreviewSize) * 0.5f, 0.0f)); - EditorWidgets::Icon( - AssetIcon, - Asset.Type == EAssetType::Model - ? EEditorColor::AccentHovered - : EEditorColor::Info, - 34.0f); - EditorWidgets::Badge( + SelectedAssetPath = Asset.AbsolutePath; + } + if (bHovered) + { + ImGui::SetTooltip( + "%s\n%s", + Asset.AbsolutePath.c_str(), Asset.Type == EAssetType::Model - ? "MODEL" - : (Asset.Type == EAssetType::Texture ? "TEXTURE" : "FILE"), - Asset.Type == EAssetType::Model ? EEditorBadge::Info : EEditorBadge::Neutral); - const bool bActivated = ImGui::Selectable( - Asset.FileName.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", Asset.AbsolutePath.c_str()); - } - BeginAssetDragSource(Asset); - if (bActivated && Asset.Type == EAssetType::Model - && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) - { - SpawnModelAsset(Asset); - } + ? "Double-click to add to the scene, or drag to the viewport." + : "Right-click for path actions."); + } + DrawAssetContextMenu(Asset); + BeginAssetDragSource(Asset); + if (bActivated + && bHovered + && Asset.Type == EAssetType::Model + && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + { + SpawnModelAsset(Asset); + } - ImGui::PushFont(FEditorStyle::Font(EEditorFont::Monospace), 0.0f); + ImDrawList* DrawList = ImGui::GetWindowDrawList(); + const ImVec2 CardMax(CardMin.x + CardWidth, CardMin.y + CardHeight); + const ImVec4 Background = FEditorStyle::Color( + bSelected + ? EEditorColor::AccentMuted + : (bHeld + ? EEditorColor::ControlActive + : (bHovered ? EEditorColor::ControlHovered : EEditorColor::PanelRaised))); + DrawList->AddRectFilled( + CardMin, + CardMax, + ImGui::GetColorU32(Background), + FEditorStyle::Scale(5.0f)); + DrawList->AddRect( + CardMin, + CardMax, + FEditorStyle::ColorU32( + bSelected ? EEditorColor::AccentHovered : EEditorColor::Border), + FEditorStyle::Scale(5.0f), + 0, + std::max(FEditorStyle::Scale(bSelected ? 1.5f : 1.0f), 1.0f)); + if (bSelected) + { + DrawList->AddRectFilled( + CardMin, + ImVec2(CardMin.x + FEditorStyle::Scale(3.0f), CardMax.y), + FEditorStyle::ColorU32(EEditorColor::AccentHovered), + FEditorStyle::Scale(2.0f)); + } + if (bFocused) + { + DrawList->AddRect( + ImVec2(CardMin.x + FEditorStyle::Scale(2.0f), CardMin.y + FEditorStyle::Scale(2.0f)), + ImVec2(CardMax.x - FEditorStyle::Scale(2.0f), CardMax.y - FEditorStyle::Scale(2.0f)), + FEditorStyle::ColorU32(EEditorColor::AccentHovered), + FEditorStyle::Scale(4.0f)); + } + + const float Padding = FEditorStyle::Scale(11.0f); + EditorWidgets::IconAt( + DrawList, + ImVec2(CardMin.x + Padding, CardMin.y + Padding), + Asset.Type == EAssetType::Model + ? EEditorIcon::Cube + : (Asset.Type == EAssetType::Texture ? EEditorIcon::Image : EEditorIcon::Folder), + Asset.Type == EAssetType::Model + ? EEditorColor::AccentHovered + : EEditorColor::Info, + 28.0f); + + const std::string Extension = GetAssetExtensionLabel(Asset.FileName); + const ImVec2 ExtensionSize = ImGui::CalcTextSize(Extension.c_str()); + DrawList->AddText( + FEditorStyle::Font(EEditorFont::Medium), + ImGui::GetFontSize(), + ImVec2(CardMax.x - Padding - ExtensionSize.x, CardMin.y + Padding), + FEditorStyle::ColorU32(EEditorColor::TextMuted), + Extension.c_str()); + + const ImVec4 TextClip( + CardMin.x + Padding, + CardMin.y, + CardMax.x - Padding, + CardMax.y); + DrawList->AddText( + FEditorStyle::Font(EEditorFont::Medium), + ImGui::GetFontSize(), + ImVec2(CardMin.x + Padding, CardMin.y + FEditorStyle::Scale(53.0f)), + FEditorStyle::ColorU32(EEditorColor::Text), + Asset.FileName.c_str(), + nullptr, + 0.0f, + &TextClip); + const std::string Location = GetAssetLocation(Asset.RelativePath); + DrawList->AddText( + FEditorStyle::Font(EEditorFont::Monospace), + ImGui::GetFontSize(), + ImVec2(CardMin.x + Padding, CardMin.y + FEditorStyle::Scale(77.0f)), + FEditorStyle::ColorU32(EEditorColor::TextMuted), + Location.c_str(), + nullptr, + 0.0f, + &TextClip); + ImGui::PopID(); + } + + ImGui::EndTable(); +} + +void FAssetsPanel::DrawSelectionBar() +{ + const FAssetEntry* SelectedAsset = GetSelectedAsset(); + ImGui::PushStyleVar( + ImGuiStyleVar_WindowPadding, + ImVec2(FEditorStyle::Scale(9.0f), FEditorStyle::Scale(6.0f))); + const bool bVisible = ImGui::BeginChild( + "AssetSelectionBar", + ImVec2(0.0f, FEditorStyle::Scale(50.0f)), + true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + if (bVisible) + { + if (!SelectedAsset) + { + EditorWidgets::Icon(EEditorIcon::Folder, EEditorColor::TextDisabled, 16.0f); + ImGui::SameLine(0.0f, FEditorStyle::Scale(8.0f)); ImGui::TextColored( FEditorStyle::Color(EEditorColor::TextMuted), - "%s", - Asset.RelativePath.c_str()); - ImGui::PopFont(); - if (Asset.Type == EAssetType::Model) + "Select an asset. Double-click or drag a model to add it to the scene."); + } + else + { + const bool bCanSpawn = SelectedAsset->Type == EAssetType::Model; + const float CopyWidth = FEditorStyle::Scale(108.0f); + const float AddWidth = bCanSpawn ? FEditorStyle::Scale(138.0f) : 0.0f; + const float ActionWidth = CopyWidth + + AddWidth + + (bCanSpawn ? ImGui::GetStyle().ItemSpacing.x : 0.0f); + if (ImGui::BeginTable( + "AssetSelectionLayout", + 2, + ImGuiTableFlags_SizingStretchProp)) { - const float ButtonY = CardHeight - ImGui::GetFrameHeight() - - ImGui::GetStyle().WindowPadding.y; - if (ImGui::GetCursorPosY() < ButtonY) + ImGui::TableSetupColumn("Selection", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn( + "Actions", + ImGuiTableColumnFlags_WidthFixed, + ActionWidth); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + EditorWidgets::Icon( + SelectedAsset->Type == EAssetType::Model + ? EEditorIcon::Cube + : (SelectedAsset->Type == EAssetType::Texture + ? EEditorIcon::Image + : EEditorIcon::Folder), + SelectedAsset->Type == EAssetType::Model + ? EEditorColor::AccentHovered + : EEditorColor::Info, + 16.0f); + ImGui::SameLine(0.0f, FEditorStyle::Scale(8.0f)); + ImGui::BeginGroup(); + ImGui::PushFont(FEditorStyle::Font(EEditorFont::Medium), 0.0f); + ImGui::TextUnformatted(SelectedAsset->FileName.c_str()); + ImGui::PopFont(); + const std::string SelectionMeta = + GetAssetLocation(SelectedAsset->RelativePath) + + " / " + SelectedAsset->TypeLabel; + ImGui::TextColored( + FEditorStyle::Color(EEditorColor::TextMuted), + "%s", + SelectionMeta.c_str()); + ImGui::EndGroup(); + + ImGui::TableSetColumnIndex(1); + if (EditorWidgets::Button( + "##CopySelectedAssetPath", + EEditorIcon::Copy, + "Copy Path", + EEditorButtonStyle::Subtle, + ImVec2(CopyWidth, 0.0f), + "Copy the relative asset path")) { - ImGui::SetCursorPosY(ButtonY); + CopyAssetPath(*SelectedAsset, false); } - if (EditorWidgets::Button( - "##AddGridAsset", - EEditorIcon::Plus, - "Add to Scene", - EEditorButtonStyle::Primary, - ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) + if (bCanSpawn) { - SpawnModelAsset(Asset); + ImGui::SameLine(); + if (EditorWidgets::Button( + "##AddSelectedAsset", + EEditorIcon::Plus, + "Add to Scene", + EEditorButtonStyle::Primary, + ImVec2(AddWidth, 0.0f), + "Queue the selected model for background loading")) + { + SpawnModelAsset(*SelectedAsset); + } } + ImGui::EndTable(); } } - ImGui::EndChild(); - ImGui::PopID(); } + ImGui::EndChild(); +} - ImGui::EndTable(); +void FAssetsPanel::DrawAssetContextMenu(const FAssetEntry& Asset) +{ + if (!ImGui::BeginPopupContextItem("AssetContext")) + { + return; + } + + SelectedAssetPath = Asset.AbsolutePath; + if (Asset.Type == EAssetType::Model + && ImGui::MenuItem("Add to Scene")) + { + SpawnModelAsset(Asset); + } + if (Asset.Type == EAssetType::Model) + { + ImGui::Separator(); + } + if (ImGui::MenuItem("Copy Relative Path")) + { + CopyAssetPath(Asset, false); + } + if (ImGui::MenuItem("Copy Absolute Path")) + { + CopyAssetPath(Asset, true); + } + ImGui::EndPopup(); } void FAssetsPanel::BeginAssetDragSource(const FAssetEntry& Asset) @@ -672,6 +974,16 @@ void FAssetsPanel::BeginAssetDragSource(const FAssetEntry& Asset) ImGui::EndDragDropSource(); } +void FAssetsPanel::CopyAssetPath(const FAssetEntry& Asset, bool bAbsolute) +{ + const std::string& Path = bAbsolute ? Asset.AbsolutePath : Asset.RelativePath; + ImGui::SetClipboardText(Path.c_str()); + LastActionMessage = std::string("Copied ") + + (bAbsolute ? "absolute" : "relative") + + " path: " + Path; + bLastActionError = false; +} + void FAssetsPanel::SpawnModelAsset(const FAssetEntry& Asset) { LastActionMessage.clear(); @@ -724,6 +1036,22 @@ const std::vector& FAssetsPanel::GetVisibleAssetIndices() return VisibleAssetIndices; } +const FAssetsPanel::FAssetEntry* FAssetsPanel::GetSelectedAsset() const +{ + if (SelectedAssetPath.empty()) + { + return nullptr; + } + const auto Selected = std::find_if( + AssetEntries.begin(), + AssetEntries.end(), + [this](const FAssetEntry& Asset) + { + return Asset.AbsolutePath == SelectedAssetPath; + }); + return Selected == AssetEntries.end() ? nullptr : &*Selected; +} + FAssetsPanel::EAssetType FAssetsPanel::ClassifyAsset(const std::string& Extension) { if (Extension == ".obj" || Extension == ".gltf" || Extension == ".glb" diff --git a/Engine/Source/UI/Panels/AssetsPanel.h b/Engine/Source/UI/Panels/AssetsPanel.h index 594148d..03dda09 100644 --- a/Engine/Source/UI/Panels/AssetsPanel.h +++ b/Engine/Source/UI/Panels/AssetsPanel.h @@ -63,20 +63,27 @@ class FAssetsPanel : public FUiPanel void DrawImportStatus(); void DrawAssetList(); void DrawAssetGrid(); + void DrawSelectionBar(); + void DrawAssetContextMenu(const FAssetEntry& Asset); void BeginAssetDragSource(const FAssetEntry& Asset); void SpawnModelAsset(const FAssetEntry& Asset); + void CopyAssetPath(const FAssetEntry& Asset, bool bAbsolute); bool MatchesCurrentFilter(const FAssetEntry& Asset) const; const std::vector& GetVisibleAssetIndices(); + const FAssetEntry* GetSelectedAsset() const; static EAssetType ClassifyAsset(const std::string& Extension); static const char* GetAssetTypeName(EAssetType Type); std::vector AssetEntries; + size_t ModelAssetCount = 0; + size_t TextureAssetCount = 0; std::vector VisibleAssetIndices; char SearchBuffer[256] = ""; std::string SearchTextLower; std::string IndexWarning; std::string LastActionMessage; + std::string SelectedAssetPath; EAssetFilter ActiveFilter = EAssetFilter::All; bool bGridView = false; bool bLastActionError = false; diff --git a/Engine/Source/UI/Panels/ViewportPanel.cpp b/Engine/Source/UI/Panels/ViewportPanel.cpp index d51505a..8b8d87a 100644 --- a/Engine/Source/UI/Panels/ViewportPanel.cpp +++ b/Engine/Source/UI/Panels/ViewportPanel.cpp @@ -84,6 +84,47 @@ namespace return std::sqrt(X * X + Y * Y); } + ImU32 ColorWithOpacity(EEditorColor Token, float Opacity) + { + ImVec4 Color = FEditorStyle::Color(Token); + Color.w *= std::clamp(Opacity, 0.0f, 1.0f); + return ImGui::GetColorU32(Color); + } + + void DrawOutlinedGizmoLine( + ImDrawList* DrawList, + const ImVec2& Start, + const ImVec2& End, + ImU32 Color, + float Thickness, + float OutlineWidth, + ImU32 OutlineColor) + { + DrawList->AddLine( + Start, + End, + OutlineColor, + Thickness + OutlineWidth * 2.0f); + DrawList->AddLine(Start, End, Color, Thickness); + } + + void DrawGizmoPivot( + ImDrawList* DrawList, + const ImVec2& Position, + float UiScale) + { + DrawList->AddCircleFilled( + Position, + 5.25f * UiScale, + ColorWithOpacity(EEditorColor::Background, 0.86f), + 20); + DrawList->AddCircleFilled( + Position, + 2.75f * UiScale, + FEditorStyle::ColorU32(EEditorColor::Text), + 16); + } + bool ProjectLocalPointToScreen( const FMatrix4x4& ModelViewProjection, const FVector3& LocalPosition, @@ -787,6 +828,8 @@ void FViewportPanel::DrawSelectionGizmo(const ImVec2& ImagePosition, const ImVec FEditorStyle::ColorU32(EEditorColor::AxisY), FEditorStyle::ColorU32(EEditorColor::AxisZ), }; + const ImU32 GizmoOutlineColor = + ColorWithOpacity(EEditorColor::Background, 0.82f); ImDrawList* DrawList = ImGui::GetWindowDrawList(); if (GizmoOperation == EGizmoOperation::Rotate) { @@ -934,25 +977,26 @@ void FViewportPanel::DrawSelectionGizmo(const ImVec2& ImagePosition, const ImVec for (int AxisIndex = 0; AxisIndex < 3; ++AxisIndex) { const bool bHighlighted = HoveredAxis == AxisIndex || ActiveGizmoAxis == AxisIndex; - const float Thickness = (bHighlighted ? 4.0f : 2.0f) * Context.UiScale; - const ImU32 RingColor = bHighlighted - ? FEditorStyle::ColorU32(EEditorColor::Warning) - : Colors[AxisIndex]; + const float Thickness = (bHighlighted ? 3.25f : 2.25f) * Context.UiScale; + const ImU32 RingColor = Colors[AxisIndex]; for (size_t PointIndex = 1; PointIndex < RingPoints[AxisIndex].size(); ++PointIndex) { - DrawList->AddLine( - RingPoints[AxisIndex][PointIndex - 1], - RingPoints[AxisIndex][PointIndex], - FEditorStyle::ColorU32(EEditorColor::Background), - Thickness + 2.0f * Context.UiScale); - } - for (size_t PointIndex = 1; PointIndex < RingPoints[AxisIndex].size(); ++PointIndex) - { - DrawList->AddLine( + if (bHighlighted) + { + DrawList->AddLine( + RingPoints[AxisIndex][PointIndex - 1], + RingPoints[AxisIndex][PointIndex], + ColorWithOpacity(EEditorColor::Text, 0.58f), + Thickness + 3.5f * Context.UiScale); + } + DrawOutlinedGizmoLine( + DrawList, RingPoints[AxisIndex][PointIndex - 1], RingPoints[AxisIndex][PointIndex], RingColor, - Thickness); + Thickness, + 1.0f * Context.UiScale, + GizmoOutlineColor); } if (!RingPoints[AxisIndex].empty()) { @@ -981,23 +1025,30 @@ void FViewportPanel::DrawSelectionGizmo(const ImVec2& ImagePosition, const ImVec { const bool bScreenHighlighted = HoveredAxis == 3 || ActiveGizmoAxis == 3; const ImU32 ScreenColor = bScreenHighlighted - ? FEditorStyle::ColorU32(EEditorColor::Warning) + ? FEditorStyle::ColorU32(EEditorColor::AccentHovered) : FEditorStyle::ColorU32(EEditorColor::TextMuted); - const float ScreenThickness = (bScreenHighlighted ? 3.5f : 1.5f) * Context.UiScale; - for (size_t PointIndex = 1; PointIndex < ScreenRingPoints.size(); ++PointIndex) - { - DrawList->AddLine( - ScreenRingPoints[PointIndex - 1], ScreenRingPoints[PointIndex], - FEditorStyle::ColorU32(EEditorColor::Background), - ScreenThickness + 2.0f * Context.UiScale); - } + const float ScreenThickness = (bScreenHighlighted ? 3.0f : 1.5f) * Context.UiScale; for (size_t PointIndex = 1; PointIndex < ScreenRingPoints.size(); ++PointIndex) { - DrawList->AddLine( - ScreenRingPoints[PointIndex - 1], ScreenRingPoints[PointIndex], - ScreenColor, ScreenThickness); + if (bScreenHighlighted) + { + DrawList->AddLine( + ScreenRingPoints[PointIndex - 1], + ScreenRingPoints[PointIndex], + ColorWithOpacity(EEditorColor::Text, 0.52f), + ScreenThickness + 3.0f * Context.UiScale); + } + DrawOutlinedGizmoLine( + DrawList, + ScreenRingPoints[PointIndex - 1], + ScreenRingPoints[PointIndex], + ScreenColor, + ScreenThickness, + 1.0f * Context.UiScale, + GizmoOutlineColor); } } + DrawGizmoPivot(DrawList, ScreenOrigin, Context.UiScale); return; } @@ -1094,46 +1145,117 @@ void FViewportPanel::DrawSelectionGizmo(const ImVec2& ImagePosition, const ImVec } ImGui::PopID(); - const float Highlight = (bHovered || bActive) ? 1.45f : 1.0f; - DrawList->AddLine( - ScreenOrigin, - End, - FEditorStyle::ColorU32(EEditorColor::Background), - 5.0f * Context.UiScale); - DrawList->AddLine(ScreenOrigin, End, Colors[AxisIndex], 2.5f * Context.UiScale * Highlight); + const bool bHighlighted = bHovered || bActive; + const float ShaftThickness = + (bHighlighted ? 3.5f : 2.75f) * Context.UiScale; + const float OutlineWidth = 1.0f * Context.UiScale; + const ImU32 StateHaloColor = ColorWithOpacity( + EEditorColor::Text, + bActive ? 0.78f : 0.48f); if (GizmoOperation == EGizmoOperation::Scale) { - const float HalfSize = 7.0f * Context.UiScale * Highlight; + DrawOutlinedGizmoLine( + DrawList, + ScreenOrigin, + End, + Colors[AxisIndex], + ShaftThickness, + OutlineWidth, + GizmoOutlineColor); + const float HalfSize = 5.5f * Context.UiScale; + if (bHighlighted) + { + const float HaloHalfSize = HalfSize + 2.0f * Context.UiScale; + DrawList->AddRectFilled( + ImVec2(End.x - HaloHalfSize, End.y - HaloHalfSize), + ImVec2(End.x + HaloHalfSize, End.y + HaloHalfSize), + StateHaloColor, + 2.5f * Context.UiScale); + } + const float OutlineHalfSize = HalfSize + OutlineWidth; + DrawList->AddRectFilled( + ImVec2(End.x - OutlineHalfSize, End.y - OutlineHalfSize), + ImVec2(End.x + OutlineHalfSize, End.y + OutlineHalfSize), + GizmoOutlineColor, + 2.25f * Context.UiScale); DrawList->AddRectFilled( ImVec2(End.x - HalfSize, End.y - HalfSize), ImVec2(End.x + HalfSize, End.y + HalfSize), Colors[AxisIndex], - 2.0f * Context.UiScale); + 1.75f * Context.UiScale); } else { const float DirectionX = DeltaX / Length2D; const float DirectionY = DeltaY / Length2D; - const float ArrowLength = 20.0f * Context.UiScale * Highlight; - const float ArrowHalfWidth = 7.0f * Context.UiScale * Highlight; + const float ArrowLength = std::min( + 13.0f * Context.UiScale, + std::max(Length2D * 0.42f, 3.0f * Context.UiScale)); + const float ArrowHalfWidth = std::clamp( + ArrowLength * 0.42f, + 2.0f * Context.UiScale, + 5.25f * Context.UiScale); const ImVec2 ArrowBase(End.x - DirectionX * ArrowLength, End.y - DirectionY * ArrowLength); const ImVec2 Perpendicular(-DirectionY, DirectionX); + const ImVec2 ArrowLeft( + ArrowBase.x + Perpendicular.x * ArrowHalfWidth, + ArrowBase.y + Perpendicular.y * ArrowHalfWidth); + const ImVec2 ArrowRight( + ArrowBase.x - Perpendicular.x * ArrowHalfWidth, + ArrowBase.y - Perpendicular.y * ArrowHalfWidth); + DrawOutlinedGizmoLine( + DrawList, + ScreenOrigin, + ImVec2( + ArrowBase.x + DirectionX * Context.UiScale, + ArrowBase.y + DirectionY * Context.UiScale), + Colors[AxisIndex], + ShaftThickness, + OutlineWidth, + GizmoOutlineColor); + if (bHighlighted) + { + const float HaloLength = ArrowLength + 2.25f * Context.UiScale; + const float HaloHalfWidth = ArrowHalfWidth + 2.0f * Context.UiScale; + const ImVec2 HaloBase( + End.x - DirectionX * HaloLength, + End.y - DirectionY * HaloLength); + DrawList->AddTriangleFilled( + ImVec2( + End.x + DirectionX * Context.UiScale, + End.y + DirectionY * Context.UiScale), + ImVec2( + HaloBase.x + Perpendicular.x * HaloHalfWidth, + HaloBase.y + Perpendicular.y * HaloHalfWidth), + ImVec2( + HaloBase.x - Perpendicular.x * HaloHalfWidth, + HaloBase.y - Perpendicular.y * HaloHalfWidth), + StateHaloColor); + } + const float OutlineLength = ArrowLength + OutlineWidth; + const float OutlineHalfWidth = ArrowHalfWidth + OutlineWidth; + const ImVec2 OutlineBase( + End.x - DirectionX * OutlineLength, + End.y - DirectionY * OutlineLength); + DrawList->AddTriangleFilled( + ImVec2( + End.x + DirectionX * OutlineWidth, + End.y + DirectionY * OutlineWidth), + ImVec2( + OutlineBase.x + Perpendicular.x * OutlineHalfWidth, + OutlineBase.y + Perpendicular.y * OutlineHalfWidth), + ImVec2( + OutlineBase.x - Perpendicular.x * OutlineHalfWidth, + OutlineBase.y - Perpendicular.y * OutlineHalfWidth), + GizmoOutlineColor); DrawList->AddTriangleFilled( End, - ImVec2(ArrowBase.x + Perpendicular.x * ArrowHalfWidth, ArrowBase.y + Perpendicular.y * ArrowHalfWidth), - ImVec2(ArrowBase.x - Perpendicular.x * ArrowHalfWidth, ArrowBase.y - Perpendicular.y * ArrowHalfWidth), + ArrowLeft, + ArrowRight, Colors[AxisIndex]); } } - const float CenterHalfSize = 4.0f * Context.UiScale; - DrawList->AddRectFilled( - ImVec2(ScreenOrigin.x - CenterHalfSize, ScreenOrigin.y - CenterHalfSize), - ImVec2(ScreenOrigin.x + CenterHalfSize, ScreenOrigin.y + CenterHalfSize), - FEditorStyle::ColorU32(EEditorColor::Text)); - DrawList->AddRect( - ImVec2(ScreenOrigin.x - CenterHalfSize, ScreenOrigin.y - CenterHalfSize), - ImVec2(ScreenOrigin.x + CenterHalfSize, ScreenOrigin.y + CenterHalfSize), - FEditorStyle::ColorU32(EEditorColor::Background)); + DrawGizmoPivot(DrawList, ScreenOrigin, Context.UiScale); } void FViewportPanel::HandleGizmoShortcuts() @@ -1150,9 +1272,25 @@ void FViewportPanel::HandleGizmoShortcuts() } return; } - if (Context.Scene->Camera.IsNavigationCaptured() - || !ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) - || ImGui::GetIO().WantTextInput) + const ImGuiIO& IO = ImGui::GetIO(); + if (Context.Scene->Camera.IsNavigationCaptured() || IO.WantTextInput) + { + return; + } + + const bool bFrameSelectionRequested = + !IO.KeyCtrl + && !IO.KeyShift + && !IO.KeyAlt + && !IO.KeySuper + && ImGui::IsKeyPressed(ImGuiKey_F, false); + if (bFrameSelectionRequested) + { + FocusSelection(); + return; + } + + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { return; } @@ -1169,10 +1307,6 @@ void FViewportPanel::HandleGizmoShortcuts() { SetGizmoOperation(EGizmoOperation::Scale); } - else if (ImGui::IsKeyPressed(ImGuiKey_F, false)) - { - FocusSelection(); - } else if (GizmoOperation != EGizmoOperation::Scale && ImGui::IsKeyPressed(ImGuiKey_Space, false)) { diff --git a/Tests/editor_domain_target_contracts.py b/Tests/editor_domain_target_contracts.py index d957ca2..a50249f 100644 --- a/Tests/editor_domain_target_contracts.py +++ b/Tests/editor_domain_target_contracts.py @@ -197,6 +197,61 @@ def validate_slice2_ownership(repository_root: Path) -> None: f"ViewportPanel retained migrated Camera registration: {method_name}" ) + shortcut_match = re.search( + r"void FViewportPanel::HandleGizmoShortcuts\(\)\s*\{(.*?)\n\}", + viewport, + re.DOTALL, + ) + if shortcut_match is None: + fail("ViewportPanel is missing gizmo shortcut routing") + shortcut_body = shortcut_match.group(1) + frame_key = shortcut_body.find("ImGui::IsKeyPressed(ImGuiKey_F, false)") + viewport_focus_gate = shortcut_body.find( + "!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)" + ) + if frame_key < 0 or viewport_focus_gate < 0: + fail("ViewportPanel is missing Frame Selected or viewport focus gating") + if frame_key > viewport_focus_gate: + fail( + "Frame Selected must be routed before the viewport focus gate so " + "Hierarchy selection can use F" + ) + for required_guard in ( + "Camera.IsNavigationCaptured()", + "IO.WantTextInput", + "!IO.KeyCtrl", + "!IO.KeyShift", + "!IO.KeyAlt", + "!IO.KeySuper", + ): + if required_guard not in shortcut_body[:frame_key]: + fail( + "global Frame Selected shortcut is missing guard: " + f"{required_guard}" + ) + + for required_visual in ( + "DrawOutlinedGizmoLine", + "DrawGizmoPivot", + "Length2D * 0.42f", + "GizmoOutlineColor", + "StateHaloColor", + ): + if required_visual not in viewport: + fail( + "Viewport gizmo visual language is missing: " + f"{required_visual}" + ) + for legacy_visual in ( + "20.0f * Context.UiScale * Highlight", + "CenterHalfSize", + ): + if legacy_visual in viewport: + fail( + "Viewport gizmo retained disproportionate legacy visual: " + f"{legacy_visual}" + ) + inspector = ( repository_root / "Engine/Source/UI/Panels/InspectorPanel.cpp" ).read_text(encoding="utf-8") From 52366ae71700ea51a8d04fd9945cb59680e451b1 Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 22:47:00 +0800 Subject: [PATCH 2/3] Route automated CRT reports to stderr --- Engine/Source/WaveEditor.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Engine/Source/WaveEditor.cpp b/Engine/Source/WaveEditor.cpp index be9f402..68004e4 100644 --- a/Engine/Source/WaveEditor.cpp +++ b/Engine/Source/WaveEditor.cpp @@ -2,10 +2,36 @@ #if defined(_WIN32) #include +#if defined(_DEBUG) +#include +#include +#endif #endif namespace { + void ConfigureAutomatedCrtReporting() + { +#if defined(_WIN32) && defined(_DEBUG) + const HANDLE StandardOutput = GetStdHandle(STD_OUTPUT_HANDLE); + if (!StandardOutput + || StandardOutput == INVALID_HANDLE_VALUE + || GetFileType(StandardOutput) != FILE_TYPE_PIPE) + { + return; + } + + // A Debug CRT modal blocks stdio automation and hides the failing + // expression from its evidence. Interactive launches keep the normal + // Retry-to-debug dialog; pipe-hosted runs fail through stderr instead. + _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); +#endif + } + void HidePrivateConsoleWindow() { #if defined(_WIN32) @@ -21,6 +47,7 @@ namespace int main() { + ConfigureAutomatedCrtReporting(); HidePrivateConsoleWindow(); return RunEditorApplication(); } From ec1a70b671f7701b1118ddfc1500f8baf6a42daa Mon Sep 17 00:00:00 2001 From: WangXin <465544815@qq.com> Date: Sun, 26 Jul 2026 22:47:16 +0800 Subject: [PATCH 3/3] Classify startup clock clamp diagnostics --- .../Core/Application/ApplicationClock.cpp | 46 ++++- .../Core/Application/ApplicationClock.h | 2 + Engine/Source/UI/EditorApplication.cpp | 68 ++++-- Tests/ApplicationContracts.cpp | 60 +++++- ...07-26-g0-02-01-clock-warmup-diagnostics.md | 193 ++++++++++++++++++ docs/specs/README.md | 1 + 6 files changed, 347 insertions(+), 23 deletions(-) create mode 100644 docs/specs/2026-07-26-g0-02-01-clock-warmup-diagnostics.md diff --git a/Engine/Source/Core/Application/ApplicationClock.cpp b/Engine/Source/Core/Application/ApplicationClock.cpp index bcaa023..a616165 100644 --- a/Engine/Source/Core/Application/ApplicationClock.cpp +++ b/Engine/Source/Core/Application/ApplicationClock.cpp @@ -3,9 +3,15 @@ #include #include #include +#include namespace WaveApplication { + namespace + { + constexpr uint8 VariableWarmupSampleBudget = 3; + } + FApplicationClock::FApplicationClock(FApplicationTimingConfig InConfig) : Config(std::move(InConfig)) { @@ -51,19 +57,41 @@ namespace WaveApplication } FApplicationClockSample Sample; + bool bWarmupClamp = false; Sample.TickSequence = TickSequence; Sample.RealTimeSeconds = MonotonicSeconds; Sample.bFirstTick = !PreviousSeconds.has_value(); Sample.RealDeltaSeconds = PreviousSeconds ? MonotonicSeconds - *PreviousSeconds : 1.0 / 60.0; - Sample.GameDeltaSeconds = std::min(Sample.RealDeltaSeconds, Config.MaxDeltaSeconds); - Sample.bClamped = Sample.RealDeltaSeconds > Config.MaxDeltaSeconds; if (Config.FixedDeltaSeconds) { Sample.GameDeltaSeconds = *Config.FixedDeltaSeconds; Sample.bFixed = true; } + else + { + Sample.GameDeltaSeconds = std::min( + Sample.RealDeltaSeconds, + Config.MaxDeltaSeconds); + Sample.bClamped = Sample.RealDeltaSeconds > Config.MaxDeltaSeconds; + if (!Sample.bFirstTick) + { + bWarmupClamp = Sample.bClamped + && !bVariableWarmupComplete + && VariableWarmupSampleCount < VariableWarmupSampleBudget; + if (!Sample.bClamped) + { + bVariableWarmupComplete = true; + } + else if (!bVariableWarmupComplete) + { + ++VariableWarmupSampleCount; + bVariableWarmupComplete = + VariableWarmupSampleCount >= VariableWarmupSampleBudget; + } + } + } PreviousSeconds = MonotonicSeconds; ++TickSequence; @@ -73,8 +101,16 @@ namespace WaveApplication Result.Diagnostics.push_back({ "application.clock.delta_clamped", "clock.game_delta_seconds", - "Variable game delta was clamped to the configured maximum.", - EApplicationDiagnosticSeverity::Warning, + std::string(bWarmupClamp + ? "Startup warmup variable game delta of " + : "Variable game delta of ") + + std::to_string(Sample.RealDeltaSeconds) + + " seconds was clamped to the configured maximum of " + + std::to_string(Config.MaxDeltaSeconds) + + " seconds.", + bWarmupClamp + ? EApplicationDiagnosticSeverity::Info + : EApplicationDiagnosticSeverity::Warning, false, }); } @@ -85,5 +121,7 @@ namespace WaveApplication { PreviousSeconds.reset(); TickSequence = 0; + VariableWarmupSampleCount = 0; + bVariableWarmupComplete = false; } } diff --git a/Engine/Source/Core/Application/ApplicationClock.h b/Engine/Source/Core/Application/ApplicationClock.h index f980f85..69037d2 100644 --- a/Engine/Source/Core/Application/ApplicationClock.h +++ b/Engine/Source/Core/Application/ApplicationClock.h @@ -36,5 +36,7 @@ namespace WaveApplication FApplicationTimingConfig Config; std::optional PreviousSeconds; uint64 TickSequence = 0; + uint8 VariableWarmupSampleCount = 0; + bool bVariableWarmupComplete = false; }; } diff --git a/Engine/Source/UI/EditorApplication.cpp b/Engine/Source/UI/EditorApplication.cpp index a0334f9..7346f85 100644 --- a/Engine/Source/UI/EditorApplication.cpp +++ b/Engine/Source/UI/EditorApplication.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -683,19 +684,52 @@ namespace return "unknown"; } - void LogApplicationDiagnostics(const FApplicationStatusSnapshot& Status) + std::string FormatApplicationDiagnostic( + const FApplicationDiagnostic& Diagnostic) { - for (const FApplicationDiagnostic& Diagnostic : Status.Diagnostics) - { - Log::Error( - "Application diagnostic [", - DiagnosticSeverityName(Diagnostic.Severity), - "] ", - Diagnostic.Code, - " at ", - Diagnostic.Path, - ": ", - Diagnostic.Message); + std::string Message = "Application diagnostic ["; + Message += DiagnosticSeverityName(Diagnostic.Severity); + Message += "] "; + Message += Diagnostic.Code; + Message += " at "; + Message += Diagnostic.Path; + Message += ": "; + Message += Diagnostic.Message; + return Message; + } + + void LogApplicationDiagnostic(const FApplicationDiagnostic& Diagnostic) + { + const std::string Message = FormatApplicationDiagnostic(Diagnostic); + switch (Diagnostic.Severity) + { + case EApplicationDiagnosticSeverity::Info: + Log::Info(Message); + break; + case EApplicationDiagnosticSeverity::Warning: + Log::Warning(Message); + break; + case EApplicationDiagnosticSeverity::Error: + case EApplicationDiagnosticSeverity::Fatal: + // Log::Fatal throws; lifecycle diagnostics have already selected the + // safe shutdown or quarantine path and must only be reported here. + Log::Error(Message); + break; + } + } + + void LogNewApplicationDiagnostics( + const FApplicationStatusSnapshot& Status, + size_t& LoggedDiagnosticCount) + { + if (LoggedDiagnosticCount > Status.Diagnostics.size()) + { + LoggedDiagnosticCount = 0; + } + while (LoggedDiagnosticCount < Status.Diagnostics.size()) + { + LogApplicationDiagnostic(Status.Diagnostics[LoggedDiagnosticCount]); + ++LoggedDiagnosticCount; } } } @@ -726,7 +760,10 @@ int RunEditorApplication() Runner.RegisterService(AdapterService); SET_THREAD_NAME("Main Thread"); - if (Runner.Initialize(IdleCoordinator)) + size_t LoggedDiagnosticCount = 0; + const bool bInitialized = Runner.Initialize(IdleCoordinator); + LogNewApplicationDiagnostics(Runner.QueryStatus(), LoggedDiagnosticCount); + if (bInitialized) { while (Runner.QueryStatus().State == EApplicationLifecycleState::Running) { @@ -742,13 +779,16 @@ int RunEditorApplication() SCOPED_CPU_EVENT("Frame"); (void)Runner.Tick(GetMonotonicSeconds()); } + LogNewApplicationDiagnostics( + Runner.QueryStatus(), + LoggedDiagnosticCount); GlobalContext.FrameIndex = Runner.QueryStatus().TickSequence; } Runner.Shutdown(IdleCoordinator); } const FApplicationStatusSnapshot& Status = Runner.QueryStatus(); - LogApplicationDiagnostics(Status); + LogNewApplicationDiagnostics(Status, LoggedDiagnosticCount); if (Status.State == EApplicationLifecycleState::Quarantined) { Log::Error( diff --git a/Tests/ApplicationContracts.cpp b/Tests/ApplicationContracts.cpp index 1f984c5..5211f04 100644 --- a/Tests/ApplicationContracts.cpp +++ b/Tests/ApplicationContracts.cpp @@ -146,9 +146,32 @@ namespace WaveContracts Require(Clamped.Sample && Clamped.Sample->bClamped, "variable delta clamped"); Require(Clamped.Sample->GameDeltaSeconds == 0.25, "clamped game delta value"); Require(HasDiagnostic(Clamped.Diagnostics, "application.clock.delta_clamped"), - "clock clamp warning"); - - const FApplicationClockResult Regressed = VariableClock.Tick(10.5); + "clock warmup clamp diagnostic"); + Require(Clamped.Diagnostics.size() == 1 + && Clamped.Diagnostics.front().Severity + == EApplicationDiagnosticSeverity::Info + && Clamped.Diagnostics.front().Message.find("Startup warmup") + != std::string::npos + && Clamped.Diagnostics.front().Message.find("1.000000") != std::string::npos + && Clamped.Diagnostics.front().Message.find("0.250000") != std::string::npos, + "clock warmup info records measured and configured delta"); + + const FApplicationClockResult SecondWarmup = VariableClock.Tick(12.0); + const FApplicationClockResult ThirdWarmup = VariableClock.Tick(13.0); + const FApplicationClockResult AfterWarmup = VariableClock.Tick(14.0); + Require(SecondWarmup.Diagnostics.size() == 1 + && SecondWarmup.Diagnostics.front().Severity + == EApplicationDiagnosticSeverity::Info + && ThirdWarmup.Diagnostics.size() == 1 + && ThirdWarmup.Diagnostics.front().Severity + == EApplicationDiagnosticSeverity::Info, + "clock warmup info has a three-sample budget"); + Require(AfterWarmup.Diagnostics.size() == 1 + && AfterWarmup.Diagnostics.front().Severity + == EApplicationDiagnosticSeverity::Warning, + "clock clamp warning resumes after warmup budget"); + + const FApplicationClockResult Regressed = VariableClock.Tick(13.5); Require(!Regressed.Sample && HasDiagnostic(Regressed.Diagnostics, "application.clock.regressed"), "clock regression rejected"); @@ -158,14 +181,41 @@ namespace WaveContracts && HasDiagnostic(NonFinite.Diagnostics, "application.clock.non_finite"), "non-finite clock rejected"); + FApplicationClock StableClock(VariableConfig); + (void)StableClock.Tick(20.0); + const FApplicationClockResult StableWarmup = StableClock.Tick(20.01); + const FApplicationClockResult StableThenClamped = StableClock.Tick(21.0); + Require(StableWarmup.Sample && !StableWarmup.Sample->bClamped + && StableWarmup.Diagnostics.empty(), + "stable variable sample ends clock warmup"); + Require(StableThenClamped.Diagnostics.size() == 1 + && StableThenClamped.Diagnostics.front().Severity + == EApplicationDiagnosticSeverity::Warning, + "clock clamp after stable sample is a warning"); + + VariableClock.Reset(); + const FApplicationClockResult ResetFirst = VariableClock.Tick(30.0); + const FApplicationClockResult ResetWarmup = VariableClock.Tick(31.0); + Require(ResetFirst.Sample && ResetFirst.Sample->bFirstTick + && ResetFirst.Sample->TickSequence == 0, + "clock reset restores first tick state"); + Require(ResetWarmup.Diagnostics.size() == 1 + && ResetWarmup.Diagnostics.front().Severity + == EApplicationDiagnosticSeverity::Info, + "clock reset restores warmup budget"); + FApplicationTimingConfig FixedConfig; FixedConfig.FixedDeltaSeconds = 0.02; FApplicationClock FixedClock(FixedConfig); Require(FixedClock.Tick(1.0).Sample->GameDeltaSeconds == 0.02, "fixed delta first tick"); - const FApplicationClockResult FixedSecond = FixedClock.Tick(1.001); + const FApplicationClockResult FixedSecond = FixedClock.Tick(2.0); Require(FixedSecond.Sample->bFixed - && FixedSecond.Sample->GameDeltaSeconds == 0.02, + && !FixedSecond.Sample->bClamped + && FixedSecond.Sample->GameDeltaSeconds == 0.02 + && !HasDiagnostic( + FixedSecond.Diagnostics, + "application.clock.delta_clamped"), "fixed game delta independent of real delta"); bool bRejectedInvalidClockConfig = false; diff --git a/docs/specs/2026-07-26-g0-02-01-clock-warmup-diagnostics.md b/docs/specs/2026-07-26-g0-02-01-clock-warmup-diagnostics.md new file mode 100644 index 0000000..7e053d0 --- /dev/null +++ b/docs/specs/2026-07-26-g0-02-01-clock-warmup-diagnostics.md @@ -0,0 +1,193 @@ +# G0-02-01:Application Clock 启动 Warmup 诊断 + +- Spec ID: `G0-02-01` +- Status: Verified +- Created: 2026-07-26 +- Updated: 2026-07-26 +- Roadmap: [G0 产品边界与 Engine Loop](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) +- Architecture: [AI-Native Engine Architecture](../architecture/ai-native-engine.md) +- Depends on: [G0-02](./2026-07-25-g0-02-engine-loop-application-config.md) +- Owners: WaveEngine maintainers +- Accepted by: User +- Accepted on: 2026-07-26 +- Supersedes: None +- Superseded by: None + +> 用户在发布前复现了两个冷启动 `delta_clamped` warning,并在确认其来自首帧 IBL/GPU warmup、不是退出重放后明确要求继续实施与合并。本 Spec 保持 game delta clamp 契约,只细化启动 warmup 的诊断 severity 与输出时机。 + +## 1. Context + +`G0-02` 规定 variable game delta 超过 0.25 秒时 clamp 并记录 warning。当前 Editor 把 lifecycle 中累积的诊断延迟到 shutdown 后统一输出,导致运行期 clamp 看起来像退出错误;改为即时输出后,冷启动首次 IBL/GPU 工作仍可产生 306 ms、683 ms 的真实慢帧 warning。 + +**Current facts** + +- `FApplicationClock::Tick()` 使用 steady-clock 相邻采样差作为 variable delta,首 tick 固定为 1/60 秒。 +- `FApplicationRunner` 在 tick 开始记录 clock diagnostic;Editor 可按 lifecycle diagnostic 游标即时输出。 +- 当前日志顺序为 `BakeEnvironmentIBL scheduled → 两个 delta clamp → MCP exit`,证明 warning 在 shutdown 前发生。 +- 同一构建热启动 WaveTrace 未复现 clamp;稳定帧约 CPU 1.3~1.8 ms、GPU 0.5~0.9 ms。 + +**Problem** + +- 冷启动的有限 warmup 帧与稳定运行期 stall 使用同一 Warning severity,制造不可行动的发布日志噪声。 +- 直接提高 `MaxDeltaSeconds` 会允许过大的 simulation delta;删除所有 warning 又会隐藏稳定运行期回退。 +- fixed-delta mode 当前不应因 real elapsed 超限产生“variable delta”诊断。 + +## 2. Goals and Non-Goals + +**Goals** + +1. 保持所有 variable delta 的 `[0, MaxDeltaSeconds]` clamp 行为不变。 +2. 首 tick 后最多三个 variable 样本属于有限 warmup;warmup clamp 使用 Info,仍保留相同稳定 code/path 与实测数值。 +3. 第一个未 clamp 的 variable 样本提前结束 warmup;预算耗尽也必须结束,避免持续慢帧永久降级。 +4. warmup 结束后的 clamp 继续使用 Warning。 +5. lifecycle diagnostic 在产生后即时、按真实 severity、仅输出一次,shutdown 只补打新增诊断。 +6. fixed-delta mode 不产生 variable clamp diagnostic;Reset 恢复完整 clock/warmup 初态。 + +**Non-Goals** + +- 不改变 0.25 秒默认上限、gameplay/Scene tick、fixed delta 数值或 frame identity。 +- 不优化或重排 IBL、RenderGraph、Present、RHI queue/fence 或 GPU warmup。 +- 不新增 config、CLI、MCP、UI、持久格式或公共 capability。 +- 不隐藏 warmup 结束后的真实运行期 stall。 + +## 3. Scenarios + +| Scenario | Given / When | Expected | +|---|---|---| +| Cold startup | 首 tick 后连续两个 delta 超限,第三个样本恢复正常 | 两次都 clamp 并记录 Info;第三个样本提前结束 warmup | +| Persistent startup stall | 首 tick 后前三个 variable 样本都超限 | 三次 Info;第四次超限必须记录 Warning | +| Stable then stall | warmup 内先出现正常样本,后续 delta 超限 | 正常样本立即结束 warmup;后续 clamp 为 Warning | +| Fixed clock | fixed delta 配置下 real elapsed 超过 max | game delta 保持 fixed;不设置 clamped、不产生 variable diagnostic | +| Reset | clock 使用后调用 `Reset()` | tick sequence、previous sample 和 warmup budget 全部回到初态 | +| Shutdown | 运行期产生 clamp 后正常退出 | 诊断在对应 tick 后输出一次,不在 shutdown 重放 | + +## 4. Constraints and Invariants + +- **Functional**:warmup 只改变 diagnostic severity,不改变 real/game delta、clamp flag、tick sequence 或 lifecycle stop。 +- **Determinism**:warmup budget 固定为首 tick后的三个 variable 样本;首个正常 variable 样本或预算耗尽结束 warmup。 +- **Threading/Lifetime**:clock 与 diagnostic cursor 仍由 application main thread 单独拥有;不新增并发状态。 +- **GPU/RenderGraph**:不修改 GPU、IBL、RenderGraph、Present 或 fence 路径;首帧性能只作为诊断来源。 +- **Data/Compatibility**:保留 `application.clock.delta_clamped` code 与 `clock.game_delta_seconds` path;Application Config schema 不变。 +- **Security/Input**:finite/regression/max-delta 校验不变;有限 budget 防止 Info 降级无限持续。 + +### AI-Native Impact + +| Dimension | Status | Decision / Verification | +|---|---|---| +| Identity | N/A | 不创建对象;沿用 application session、tick sequence 与 diagnostic code/path | +| Schema | Required | code/path 与 config schema 保持兼容;severity 的 warmup 条件由本 Spec 明确定义并由 CPU contract 固定 | +| Query | Required | lifecycle status 继续以单调 revision 暴露完整 diagnostic snapshot;Editor 的本地游标只防日志重放,不改变事实集合 | +| Effect | N/A | clock sampling 与 diagnostic observation 不产生可 Undo 写 effect | +| Determinism | Required | 三样本上限、首个稳定样本提前结束、Reset 语义和 fixed/variable 分支由纯 CPU contract 固定 | +| Validation/Evidence | Required | CPU clock corpus、Debug/Release build/CTest、冷启动日志顺序和稳定帧 Trace 共同留证 | +| Provenance/Migration | N/A | 无持久 artifact/schema 迁移;G0-02-01 记录对既有 G0-02 诊断语义的兼容细化 | +| Security/Budget | Required | warmup 最多三个 variable 样本;不能永久压低持续 stall severity | +| Headless | Required | `WaveCoreContracts` 无窗口验证全部状态机语义;Editor 仅验证即时日志 adapter | + +## 5. Design + +```text +steady-clock sample + → first tick synthetic 1/60 (不计 warmup sample) + → variable sample + ├─ fixed mode: fixed game delta,无 variable diagnostic + └─ variable mode: clamp + ├─ warmup active + clamped → Info + ├─ stable sample → warmup complete + └─ budget exhausted / warmup complete + clamped → Warning + → lifecycle diagnostic snapshot + → Editor diagnostic cursor → severity-aware log once +``` + +- **Interfaces**:不新增接口;`FApplicationClock` 增加私有 warmup sample count/completion state。 +- **State/Control Flow**:首 tick 不消费 budget;每个后续 variable sample 消费一个 budget。正常样本优先结束 warmup;第三个样本后强制结束。 +- **Persistence/Migration**:不适用。 +- **Observability**:Info/Warning 都包含 measured delta 与 configured max;code/path 保持稳定。Editor 用已输出数量作为进程内游标。 + +## 6. Alternatives + +| Alternative | Benefit | Cost / Rejection reason | +|---|---|---| +| 提高默认 max delta | 不再出现当前 warning | simulation 可收到 306~683 ms 大步长,破坏 G0-02 clamp 不变量 | +| 删除或永久降级 clamp warning | 日志安静 | 隐藏稳定运行期卡顿,Evidence 失真 | +| 只按固定 N 帧结束 warmup | 实现简单 | 已稳定的第二帧之后仍可能错误降级;采用“首个稳定或 budget”双门 | +| 本 Spec 优化 IBL/RHI warmup | 可能消除根因 | 触及 Renderer/RHI/GPU 生命周期,范围与风险远超诊断语义修复 | + +## 7. Delivery Slices + +| Slice | Scope | Invariant | Verification | Status | +|---|---|---|---|---| +| 1 | Clock warmup state、severity、fixed/reset CPU contract | clamp 数值不变,Info 最多三个样本 | Debug/Release CoreRuntimeContracts | Verified | +| 2 | Editor diagnostic 即时 severity-aware 单次输出 | 运行期诊断不在退出重放 | Debug/Release Editor、正常退出、注入 stall 日志顺序 | Verified | +| 3 | 全量回归、Evidence 与发布收口 | 无新增 Error/Fatal/D3D12 validation | Debug/Release build/CTest、日志/Trace | Verified | + +## 8. Verification and Acceptance + +| Layer | Command/Steps | Expected | Required | +|---|---|---|---| +| Static | `git diff --check`;SpecContracts | 无格式/Spec 契约错误 | Yes | +| CPU Debug | 构建 Debug `WaveCoreContracts`;运行 `CoreRuntimeContracts` | warmup/fixed/reset/steady-stall 全部通过 | Yes | +| CPU Release | 构建 Release `WaveCoreContracts`;运行 `CoreRuntimeContracts` | warning-as-error 与 Release 语义通过 | Yes | +| Editor | Debug/Release `WaveEditor` build | severity-aware adapter 两配置编译通过 | Yes | +| Full CTest | Debug 与 Release 全量 CTest | 全部现有契约通过 | Yes | +| Runtime | 正常退出;冷启动/注入 stall;扫描 UserSaved log | warmup Info、稳定期 Warning、单次输出、无退出重放 | Yes | + +### Acceptance Criteria + +- [x] variable game delta 的数值 clamp 与 0.25 秒默认上限不变。 +- [x] warmup Info 最多覆盖首 tick 后三个 variable 样本,并可由首个稳定样本提前结束。 +- [x] warmup 后超限仍记录 Warning;fixed mode 不产生 variable clamp diagnostic。 +- [x] Reset 恢复 clock 与 warmup 状态。 +- [x] Editor 即时按 severity 输出且不在 shutdown 重放。 +- [x] Debug/Release、全量 CTest 与 runtime Evidence 在当前实现通过。 +- [x] 注册表与 Implementation Record 同步。 + +## 9. Compatibility, Risks, and Open Questions + +- **Compatibility/Migration**:diagnostic code/path、config schema、game delta 与 lifecycle snapshot 兼容;只有 warmup 条件下 severity 从 Warning 变为 Info。 +- **Removal/Rollback**:可独立回退 warmup state;不得回退即时单次输出或 fixed mode 的 variable 误报修复。 + +| Risk | Impact | Mitigation | +|---|---|---| +| 持续卡顿被长期当作 Info | 隐藏性能故障 | hard budget 三个 variable 样本,第四次必为 Warning | +| 正常样本后再次进入 warmup | 运行期 warning 被压低 | warmup completion 单调;只有显式 Reset 才恢复 | +| 只修日志掩盖 GPU 性能问题 | 冷启动仍慢 | measured delta 与 Trace 保留;Renderer/RHI 优化必须另立 Spec | + +### Open Questions + +无。用户已明确接受 warmup budget、提前稳定门和后续 Warning 语义。 + +## 10. Implementation Record + +### Current Progress + +- 已确认原“退出 warning”是 lifecycle diagnostic 延迟打印;Editor 已改为按游标即时、severity-aware、单次输出。 +- 用户复现的两次 306 ms、683 ms clamp 位于 `BakeEnvironmentIBL scheduled` 与 MCP shutdown 之间。 +- 热启动 WaveTrace 未复现 clamp,稳定帧约 CPU 1.3~1.8 ms、GPU 0.5~0.9 ms。 +- 用户已接受“最多三个 variable warmup 样本,首个稳定样本提前结束,之后恢复 Warning”的契约。 +- `FApplicationClock` 已实现有界 warmup state;CPU contract 覆盖三次 Info、第四次 Warning、稳定样本提前结束、Reset 与 fixed-delta。 +- Editor diagnostic adapter 已按 lifecycle vector 游标即时输出,并按 Info/Warning/Error/Fatal 映射日志 severity。 +- Debug/Release 全量构建与各 17 项 CTest 已通过;真实 IBL 首帧注入为 Info,稳定第 10 帧后的注入仍为 Warning。 + +### Next Step + +- G0-02-01 已 Verified;进入 Git 提交、推送与 Draft PR 合并流程。 + +### Changes and Deviations + +- 相对 G0-02 的“所有超限均 Warning”,仅在有界启动 warmup 内改为 Info;clamp 数值与结构化 code/path 不变。 + +### Evidence + +| Date | Commit | Environment | Command/Steps | Result | Artifacts | +|---|---|---|---|---|---| +| 2026-07-26 | working tree | Windows 11 / Debug / DX12 validation | 正常 exit;进程暂停注入 0.75 秒 stall;检查 stderr 顺序 | Pass | clamp 只输出一次且位于 MCP shutdown 前,实测 0.780884 s | +| 2026-07-26 | working tree | Windows 11 / Debug / WaveTrace | 首帧 capture + frame/pass/top-scope sampling | Pass | 热启动无 clamp;稳定 CPU/GPU frame stats | +| 2026-07-26 | working tree | Windows 11 / VS 2022 / Debug+Release | 两配置完整 build;`ctest --test-dir Build -C Debug/Release --output-on-failure` | Pass | Debug 17/17;Release 17/17 | +| 2026-07-26 | working tree | Windows 11 / Debug / DX12 validation | 在 `BakeEnvironmentIBL scheduled` 时暂停 0.75 秒;正常 `request_exit` | Pass | startup delta 0.785883 s 记录为 Info,shutdown 前单次输出 | +| 2026-07-26 | working tree | Windows 11 / Debug / DX12 validation | 稳定到 frame 10 后暂停 0.75 秒;正常 `request_exit` | Pass | runtime delta 0.752670 s 记录为 Warning,shutdown 前单次输出 | +| 2026-07-26 | working tree | UserSaved log | 扫描 Error、Fatal 与 D3D12 error/corruption | Pass | 无匹配 | + +### Remaining Work + +- 无。本 Spec 已满足 Acceptance Criteria。 diff --git a/docs/specs/README.md b/docs/specs/README.md index 790e93e..39fa759 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -23,6 +23,7 @@ Draft / Accepted / Implementing --被替代--> Superseded | [A0-04](./2026-07-25-a0-04-headless-agent-conformance.md) | Accepted | Headless Developer Host、Adapter Conformance 与 Agent Eval | [AI-native track](../plans/2026-07-25-game-engine-production-roadmap.md#51-ai-native-横向设计) | 2026-07-25 | | [G0-01](./2026-07-25-g0-01-runtime-editor-targets.md) | Verified | Runtime 与 Editor Target 边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | | [G0-02](./2026-07-25-g0-02-engine-loop-application-config.md) | Verified | Engine Loop、Application Config 与 Lifecycle | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | +| [G0-02-01](./2026-07-26-g0-02-01-clock-warmup-diagnostics.md) | Verified | Application Clock 启动 Warmup 诊断 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | | [G0-03](./2026-07-25-g0-03-project-package-user-paths.md) | Verified | Project、Package、User Paths 与原子存储边界 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 | | [G0-04](./2026-07-26-g0-04-existing-editor-domain-capabilities.md) | Verified | 现有 Editor 领域能力 AI-Native 收口 | [G0](../plans/2026-07-25-game-engine-production-roadmap.md#6-g0产品边界与-engine-loop) | 2026-07-26 |