diff --git a/docs/plan/NewEditor_LiveResizeJitterRootCleanupPlan_2026-04-22.md b/docs/plan/NewEditor_LiveResizeJitterRootCleanupPlan_2026-04-22.md new file mode 100644 index 00000000..7f1b0cd8 --- /dev/null +++ b/docs/plan/NewEditor_LiveResizeJitterRootCleanupPlan_2026-04-22.md @@ -0,0 +1,88 @@ +# NewEditor Live Resize Jitter Root Cleanup Plan + +Date: 2026-04-22 +Status: In Progress + +## 1. Objective + +Clean up the remaining `new_editor` live-resize jitter without weakening the already-restored anti-deformation contract. + +The target contract remains: + +1. host-driven borderless resize must still be able to present target-size content before the native window visibly adopts that size +2. Win32 lifecycle handling must not reintroduce stretched stale content +3. resize-time rendering policy must stay centralized and structurally clean instead of being spread across message handlers and chrome code + +## 2. Confirmed Root Cause + +The current jitter is caused by two structural problems acting together: + +1. live borderless resize still drives a real swap-chain resize on each pointer step +2. that resize path still performs a hard D3D12 synchronization before touching swap-chain buffers +3. the same resize step can then be rendered again from `WM_SIZE`, `WM_PAINT`, and the steady-state main loop + +So the root issue is not the custom title bar design. The custom title bar is the mechanism that preserves anti-deformation. The remaining problem is the host-side resize / present execution strategy under it. + +## 3. Red Lines + +This cleanup must not: + +1. remove the custom title-bar-driven anti-deformation flow +2. move resize / present ownership into the Editor UI content layer +3. add scattered `if (interactiveResize)` patches across unrelated code +4. create a second parallel frame executor beside the existing frame driver +5. weaken D3D12 lifetime safety by blindly deleting resize synchronization without replacing it with a narrower correct rule + +## 4. Target End State + +After the cleanup: + +1. immediate resize-time frames still go through one shared frame-driving entry point +2. predicted borderless resize transitions present at most one intentional immediate frame per interaction step +3. `WM_SIZE` only acknowledges a predicted resize that was already presented and does not redundantly render it again +4. the next steady-state app-loop frame is skipped once after an already-presented immediate resize frame +5. live resize waits only for tracked in-flight frame retirement instead of forcing a fresh queue-wide idle synchronization each step + +## 5. Implementation Plan + +### Phase A. Re-centralize immediate resize-time frame execution + +1. add an explicit immediate-frame path on top of `EditorWindowFrameDriver` +2. route borderless predicted transitions through that shared driver instead of calling `RenderFrame(...)` directly +3. keep transfer-request handling ownership unchanged + +### Phase B. Add predicted-presentation acknowledgement state + +1. extend host runtime state so predicted client size can remember whether it has already been presented +2. let borderless predicted transitions mark the prediction as presented only after the immediate frame path runs +3. teach `WM_SIZE` handling to consume that acknowledgement and skip its redundant render when the incoming size is only the native echo of an already-presented prediction + +### Phase C. Eliminate the immediate-frame / steady-state double render + +1. add one-shot state that skips the next steady-state frame after an immediate synchronous frame already presented the same window +2. consume that skip only inside `EditorWindowHostRuntime::RenderAllWindows(...)` +3. keep normal steady-state rendering unchanged for all other cases + +### Phase D. Narrow D3D12 live-resize synchronization + +1. add a host-device helper that waits only for tracked submitted frame slots to retire +2. use that narrower helper from live swap-chain resize +3. keep full `WaitForGpuIdle()` for shutdown / destruction paths where full teardown semantics are still required + +## 6. Validation Requirements + +Before completion: + +1. `XCUIEditorApp` must build successfully after the cleanup +2. borderless live resize must still avoid content deformation +3. redundant `WM_SIZE` + steady-state resize-time frame duplication must be structurally removed from the new code path +4. no unrelated docking / detach / utility-window behavior may regress + +## 7. Completion Criteria + +This work is complete only when: + +1. live resize keeps the anti-deformation contract +2. one resize interaction no longer fans out into multiple avoidable frame executions +3. D3D12 live-resize synchronization is reduced from queue-wide idle to tracked frame retirement +4. the resulting code remains a mainline host/render lifecycle cleanup instead of a symptom patch diff --git a/new_editor/app/Platform/Win32/EditorWindow.cpp b/new_editor/app/Platform/Win32/EditorWindow.cpp index 97f34600..6807f60b 100644 --- a/new_editor/app/Platform/Win32/EditorWindow.cpp +++ b/new_editor/app/Platform/Win32/EditorWindow.cpp @@ -362,40 +362,38 @@ bool EditorWindow::TryResolveDockTabDropTarget( return outTarget.valid; } -void EditorWindow::OnResize(UINT width, UINT height) { - bool matchesPredictedClientSize = false; - UINT predictedWidth = 0u; - UINT predictedHeight = 0u; - if (m_chromeController->TryGetPredictedClientPixelSize( - predictedWidth, - predictedHeight)) { - matchesPredictedClientSize = - predictedWidth == width && - predictedHeight == height; +bool EditorWindow::OnResize(UINT width, UINT height) { + const bool matchedPresentedPrediction = + m_chromeController->ConsumePresentedPredictedClientPixelSizeMatch(width, height); + if (!matchedPresentedPrediction) { + m_chromeController->ClearPredictedClientPixelSize(); } - - m_chromeController->ClearPredictedClientPixelSize(); if (IsBorderlessWindowEnabled() && m_state->window.hwnd != nullptr) { Host::RefreshBorderlessWindowDwmDecorations(m_state->window.hwnd); } - if (!matchesPredictedClientSize) { - ApplyWindowResize(width, height); + if (matchedPresentedPrediction) { + return false; } + + ApplyWindowResize(width, height); + return true; } void EditorWindow::OnEnterSizeMove() { m_chromeController->BeginInteractiveResize(); } -void EditorWindow::OnExitSizeMove() { +bool EditorWindow::OnExitSizeMove() { m_chromeController->EndInteractiveResize(); m_chromeController->ClearPredictedClientPixelSize(); UINT width = 0u; UINT height = 0u; if (QueryCurrentClientPixelSize(width, height)) { ApplyWindowResize(width, height); + return true; } + return false; } void EditorWindow::OnDpiChanged(UINT dpi, const RECT& suggestedRect) { @@ -658,6 +656,7 @@ EditorWindowFrameTransferRequests EditorWindow::OnPaintMessage( BeginPaint(m_state->window.hwnd, &paintStruct); const EditorWindowFrameTransferRequests transferRequests = RenderFrame(editorContext, globalTabDragActive); + m_chromeController->RequestSkipNextSteadyStateFrame(); EndPaint(m_state->window.hwnd, &paintStruct); return transferRequests; } diff --git a/new_editor/app/Platform/Win32/EditorWindow.h b/new_editor/app/Platform/Win32/EditorWindow.h index e3dc6215..09fadac8 100644 --- a/new_editor/app/Platform/Win32/EditorWindow.h +++ b/new_editor/app/Platform/Win32/EditorWindow.h @@ -145,9 +145,9 @@ private: EditorWindowFrameTransferRequests OnPaintMessage( EditorContext& editorContext, bool globalTabDragActive); - void OnResize(UINT width, UINT height); + bool OnResize(UINT width, UINT height); void OnEnterSizeMove(); - void OnExitSizeMove(); + bool OnExitSizeMove(); void OnDpiChanged(UINT dpi, const RECT& suggestedRect); bool IsBorderlessWindowEnabled() const; diff --git a/new_editor/app/Platform/Win32/EditorWindowChromeController.cpp b/new_editor/app/Platform/Win32/EditorWindowChromeController.cpp index ff4315eb..0f739b99 100644 --- a/new_editor/app/Platform/Win32/EditorWindowChromeController.cpp +++ b/new_editor/app/Platform/Win32/EditorWindowChromeController.cpp @@ -1,6 +1,7 @@ #include "Platform/Win32/EditorWindowChromeController.h" #include "Platform/Win32/EditorWindow.h" +#include "Platform/Win32/EditorWindowFrameDriver.h" #include "Platform/Win32/EditorWindowRuntimeController.h" #include "Platform/Win32/EditorWindowState.h" #include "Platform/Win32/EditorWindowSupport.h" @@ -137,6 +138,24 @@ bool EditorWindowChromeController::TryGetPredictedClientPixelSize( return m_runtimeState.TryGetPredictedClientPixelSize(outWidth, outHeight); } +void EditorWindowChromeController::MarkPredictedClientPixelSizePresented() { + m_runtimeState.MarkPredictedClientPixelSizePresented(); +} + +bool EditorWindowChromeController::ConsumePresentedPredictedClientPixelSizeMatch( + UINT width, + UINT height) { + return m_runtimeState.ConsumePresentedPredictedClientPixelSizeMatch(width, height); +} + +void EditorWindowChromeController::RequestSkipNextSteadyStateFrame() { + m_runtimeState.RequestSkipNextSteadyStateFrame(); +} + +bool EditorWindowChromeController::ConsumeSkipNextSteadyStateFrame() { + return m_runtimeState.ConsumeSkipNextSteadyStateFrame(); +} + void EditorWindowChromeController::SetBorderlessWindowMaximized(bool maximized) { m_runtimeState.SetBorderlessWindowMaximized(maximized); } @@ -297,8 +316,6 @@ bool EditorWindowChromeController::HandleResizePointerMove( EditorWindow& window, EditorContext& editorContext, bool globalTabDragActive) { - (void)editorContext; - (void)globalTabDragActive; if (!IsBorderlessResizeActive() || window.m_state->window.hwnd == nullptr) { return false; @@ -326,8 +343,13 @@ bool EditorWindowChromeController::HandleResizePointerMove( SetPredictedClientPixelSize( static_cast(width), static_cast(height)); - window.ApplyWindowResize(static_cast(width), static_cast(height)); - (void)window.RenderFrame(editorContext, globalTabDragActive); + if (window.ApplyWindowResize(static_cast(width), static_cast(height))) { + (void)EditorWindowFrameDriver::DriveImmediateFrame( + window, + editorContext, + globalTabDragActive); + MarkPredictedClientPixelSizePresented(); + } SetWindowPos( window.m_state->window.hwnd, @@ -794,8 +816,6 @@ bool EditorWindowChromeController::ApplyPredictedWindowRectTransition( EditorContext& editorContext, bool globalTabDragActive, const RECT& targetRect) { - (void)editorContext; - (void)globalTabDragActive; if (window.m_state->window.hwnd == nullptr) { return false; } @@ -807,8 +827,13 @@ bool EditorWindowChromeController::ApplyPredictedWindowRectTransition( } SetPredictedClientPixelSize(static_cast(width), static_cast(height)); - window.ApplyWindowResize(static_cast(width), static_cast(height)); - (void)window.RenderFrame(editorContext, globalTabDragActive); + if (window.ApplyWindowResize(static_cast(width), static_cast(height))) { + (void)EditorWindowFrameDriver::DriveImmediateFrame( + window, + editorContext, + globalTabDragActive); + MarkPredictedClientPixelSizePresented(); + } SetWindowPos( window.m_state->window.hwnd, nullptr, diff --git a/new_editor/app/Platform/Win32/EditorWindowChromeController.h b/new_editor/app/Platform/Win32/EditorWindowChromeController.h index 9c35610e..a2207817 100644 --- a/new_editor/app/Platform/Win32/EditorWindowChromeController.h +++ b/new_editor/app/Platform/Win32/EditorWindowChromeController.h @@ -52,6 +52,10 @@ public: void SetPredictedClientPixelSize(UINT width, UINT height); void ClearPredictedClientPixelSize(); bool TryGetPredictedClientPixelSize(UINT& outWidth, UINT& outHeight) const; + void MarkPredictedClientPixelSizePresented(); + bool ConsumePresentedPredictedClientPixelSizeMatch(UINT width, UINT height); + void RequestSkipNextSteadyStateFrame(); + bool ConsumeSkipNextSteadyStateFrame(); void SetBorderlessWindowMaximized(bool maximized); bool IsBorderlessWindowMaximized() const; diff --git a/new_editor/app/Platform/Win32/EditorWindowFrameDriver.cpp b/new_editor/app/Platform/Win32/EditorWindowFrameDriver.cpp index 92d9ee33..bae00f60 100644 --- a/new_editor/app/Platform/Win32/EditorWindowFrameDriver.cpp +++ b/new_editor/app/Platform/Win32/EditorWindowFrameDriver.cpp @@ -1,13 +1,15 @@ #include "Platform/Win32/EditorWindowFrameDriver.h" +#include "Platform/Win32/EditorWindowChromeController.h" #include "Platform/Win32/EditorWindow.h" namespace XCEngine::UI::Editor::App { -EditorWindowFrameTransferRequests EditorWindowFrameDriver::DriveFrame( +EditorWindowFrameTransferRequests EditorWindowFrameDriver::DriveFrameInternal( EditorWindow& window, EditorContext& editorContext, - bool globalTabDragActive) { + bool globalTabDragActive, + bool requestSkipNextSteadyStateFrame) { if (!window.IsRenderReady() || window.GetHwnd() == nullptr || window.IsClosing()) { @@ -20,8 +22,25 @@ EditorWindowFrameTransferRequests EditorWindowFrameDriver::DriveFrame( hwnd != nullptr && IsWindow(hwnd)) { ValidateRect(hwnd, nullptr); } + if (requestSkipNextSteadyStateFrame) { + window.m_chromeController->RequestSkipNextSteadyStateFrame(); + } return transferRequests; } +EditorWindowFrameTransferRequests EditorWindowFrameDriver::DriveFrame( + EditorWindow& window, + EditorContext& editorContext, + bool globalTabDragActive) { + return DriveFrameInternal(window, editorContext, globalTabDragActive, false); +} + +EditorWindowFrameTransferRequests EditorWindowFrameDriver::DriveImmediateFrame( + EditorWindow& window, + EditorContext& editorContext, + bool globalTabDragActive) { + return DriveFrameInternal(window, editorContext, globalTabDragActive, true); +} + } // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Platform/Win32/EditorWindowFrameDriver.h b/new_editor/app/Platform/Win32/EditorWindowFrameDriver.h index 941c9410..6cbcc05e 100644 --- a/new_editor/app/Platform/Win32/EditorWindowFrameDriver.h +++ b/new_editor/app/Platform/Win32/EditorWindowFrameDriver.h @@ -13,6 +13,17 @@ public: EditorWindow& window, EditorContext& editorContext, bool globalTabDragActive); + static EditorWindowFrameTransferRequests DriveImmediateFrame( + EditorWindow& window, + EditorContext& editorContext, + bool globalTabDragActive); + +private: + static EditorWindowFrameTransferRequests DriveFrameInternal( + EditorWindow& window, + EditorContext& editorContext, + bool globalTabDragActive, + bool requestSkipNextSteadyStateFrame); }; } // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Platform/Win32/HostRuntimeState.h b/new_editor/app/Platform/Win32/HostRuntimeState.h index 96017a6a..1d5f81aa 100644 --- a/new_editor/app/Platform/Win32/HostRuntimeState.h +++ b/new_editor/app/Platform/Win32/HostRuntimeState.h @@ -18,6 +18,7 @@ struct PredictedClientPixelSize { bool active = false; UINT width = 0u; UINT height = 0u; + bool presented = false; }; struct BorderlessWindowPlacementState { @@ -40,6 +41,7 @@ public: m_predictedClientPixelSize = {}; m_borderlessWindowPlacementState = {}; m_borderlessWindowDragRestoreState = {}; + m_skipNextSteadyStateFrame = false; } void SetWindowDpi(UINT dpi) { @@ -120,6 +122,7 @@ public: m_predictedClientPixelSize.active = true; m_predictedClientPixelSize.width = width; m_predictedClientPixelSize.height = height; + m_predictedClientPixelSize.presented = false; } void ClearPredictedClientPixelSize() { @@ -140,6 +143,36 @@ public: return true; } + void MarkPredictedClientPixelSizePresented() { + if (!m_predictedClientPixelSize.active) { + return; + } + + m_predictedClientPixelSize.presented = true; + } + + bool ConsumePresentedPredictedClientPixelSizeMatch(UINT width, UINT height) { + if (!m_predictedClientPixelSize.active || + !m_predictedClientPixelSize.presented || + m_predictedClientPixelSize.width != width || + m_predictedClientPixelSize.height != height) { + return false; + } + + m_predictedClientPixelSize = {}; + return true; + } + + void RequestSkipNextSteadyStateFrame() { + m_skipNextSteadyStateFrame = true; + } + + bool ConsumeSkipNextSteadyStateFrame() { + const bool skipFrame = m_skipNextSteadyStateFrame; + m_skipNextSteadyStateFrame = false; + return skipFrame; + } + void SetBorderlessWindowMaximized(bool maximized) { m_borderlessWindowPlacementState.maximized = maximized; } @@ -187,6 +220,7 @@ private: PredictedClientPixelSize m_predictedClientPixelSize = {}; BorderlessWindowPlacementState m_borderlessWindowPlacementState = {}; BorderlessWindowDragRestoreState m_borderlessWindowDragRestoreState = {}; + bool m_skipNextSteadyStateFrame = false; }; } // namespace XCEngine::UI::Editor::Host diff --git a/new_editor/app/Platform/Win32/WindowManager/EditorWindowHostRuntime.cpp b/new_editor/app/Platform/Win32/WindowManager/EditorWindowHostRuntime.cpp index 84c4a2bb..63070fc8 100644 --- a/new_editor/app/Platform/Win32/WindowManager/EditorWindowHostRuntime.cpp +++ b/new_editor/app/Platform/Win32/WindowManager/EditorWindowHostRuntime.cpp @@ -2,6 +2,7 @@ #include "Bootstrap/EditorResources.h" #include "Composition/EditorContext.h" +#include "Platform/Win32/EditorWindowChromeController.h" #include "Platform/Win32/EditorWindow.h" #include "Platform/Win32/EditorWindowContentController.h" #include "Platform/Win32/EditorWindowFrameDriver.h" @@ -186,6 +187,12 @@ void EditorWindowHostRuntime::RenderAllWindows( continue; } + if (window->m_chromeController->ConsumeSkipNextSteadyStateFrame()) { + workspaceCoordinator.RefreshWindowPresentation(*window); + utilityCoordinator.RefreshWindowPresentation(*window); + continue; + } + EditorWindowFrameTransferRequests transferRequests = EditorWindowFrameDriver::DriveFrame( *window, diff --git a/new_editor/app/Platform/Win32/WindowManager/EditorWindowMessageDispatcher.cpp b/new_editor/app/Platform/Win32/WindowManager/EditorWindowMessageDispatcher.cpp index 907b453b..fb3f4292 100644 --- a/new_editor/app/Platform/Win32/WindowManager/EditorWindowMessageDispatcher.cpp +++ b/new_editor/app/Platform/Win32/WindowManager/EditorWindowMessageDispatcher.cpp @@ -75,7 +75,7 @@ void EditorWindowMessageDispatcher::DispatchWindowFrameTransferRequests( } void EditorWindowMessageDispatcher::RenderAndHandleWindowFrame(const DispatchContext& context) { - EditorWindowFrameTransferRequests transferRequests = EditorWindowFrameDriver::DriveFrame( + EditorWindowFrameTransferRequests transferRequests = EditorWindowFrameDriver::DriveImmediateFrame( context.window, context.hostRuntime.GetEditorContext(), context.workspaceCoordinator.IsGlobalTabDragActive()); @@ -414,16 +414,19 @@ bool EditorWindowMessageDispatcher::TryDispatchWindowLifecycleMessage( outResult = 0; return true; case WM_EXITSIZEMOVE: - context.window.OnExitSizeMove(); - RenderAndHandleWindowFrame(context); + if (context.window.OnExitSizeMove()) { + RenderAndHandleWindowFrame(context); + } outResult = 0; return true; case WM_SIZE: if (wParam != SIZE_MINIMIZED) { - context.window.OnResize( + const bool requiresImmediateFrame = context.window.OnResize( static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); - RenderAndHandleWindowFrame(context); + if (requiresImmediateFrame) { + RenderAndHandleWindowFrame(context); + } } outResult = 0; return true; diff --git a/new_editor/app/Rendering/D3D12/D3D12HostDevice.cpp b/new_editor/app/Rendering/D3D12/D3D12HostDevice.cpp index 0e4a06ba..3036fa56 100644 --- a/new_editor/app/Rendering/D3D12/D3D12HostDevice.cpp +++ b/new_editor/app/Rendering/D3D12/D3D12HostDevice.cpp @@ -229,6 +229,14 @@ void D3D12HostDevice::WaitForFrame(std::uint32_t frameIndex) { } } +void D3D12HostDevice::WaitForSubmittedFrames() { + for (std::uint32_t frameIndex = 0u; + frameIndex < m_frameFenceValues.size(); + ++frameIndex) { + WaitForFrame(frameIndex); + } +} + void D3D12HostDevice::WaitForGpuIdle() { { std::ostringstream stream = {}; diff --git a/new_editor/app/Rendering/D3D12/D3D12HostDevice.h b/new_editor/app/Rendering/D3D12/D3D12HostDevice.h index 465eed0b..017f3e5e 100644 --- a/new_editor/app/Rendering/D3D12/D3D12HostDevice.h +++ b/new_editor/app/Rendering/D3D12/D3D12HostDevice.h @@ -33,6 +33,7 @@ public: bool SubmitFrame(std::uint32_t frameIndex); bool SignalFrameCompletion(std::uint32_t frameIndex); void WaitForFrame(std::uint32_t frameIndex); + void WaitForSubmittedFrames(); void WaitForGpuIdle(); void ResetFrameTracking(); diff --git a/new_editor/app/Rendering/D3D12/D3D12WindowSwapChainPresenter.cpp b/new_editor/app/Rendering/D3D12/D3D12WindowSwapChainPresenter.cpp index 589472ac..6bf79b59 100644 --- a/new_editor/app/Rendering/D3D12/D3D12WindowSwapChainPresenter.cpp +++ b/new_editor/app/Rendering/D3D12/D3D12WindowSwapChainPresenter.cpp @@ -339,7 +339,7 @@ bool D3D12WindowSwapChainPresenter::Resize(int width, int height) { return true; } - m_hostDevice->WaitForGpuIdle(); + m_hostDevice->WaitForSubmittedFrames(); ReleaseBackBufferCommandReferences(); ReleaseBackBufferViews();