AtlasEngine: don't render frames that won't be presented - #20667
Open
Teera (teeramusic) wants to merge 1 commit into
Open
AtlasEngine: don't render frames that won't be presented#20667Teera (teeramusic) wants to merge 1 commit into
Teera (teeramusic) wants to merge 1 commit into
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
Dustin L. Howett (DHowett)
requested changes
Sep 11, 2026
Dustin L. Howett (DHowett)
left a comment
Member
There was a problem hiding this comment.
Looks like you made the mistake of not reading the issue you're solving:
dirtyRectInPx is not suitable. Read the thread.
Member
|
To be fair it's hard to spot the absence of rendering haha. My recommendation: presentmon. Check if WindowsTerminal.exe still renders when scrolled up and printing text. |
Renderer::_PaintFrame calls Present() for every frame it paints, and AtlasEngine::Present() always runs the backend's Render() before _present() decides whether there's anything to hand to Present1(). When the dirty rect is empty the frame is dropped, but it has already been rendered, and some drivers only recycle the intermediate resources (mapped constant/vertex buffers etc.) once a frame is actually presented. Every dropped frame therefore leaks a little, and such frames come in continuously in two everyday situations: * a pane is scrolled up while output keeps arriving below the viewport * a TUI hides the cursor and keeps repositioning it: Cursor::_RedrawCursor schedules a frame whenever the cursor is "on", regardless of visibility, while Renderer::_invalidateCurrentCursor only invalidates visible ones Skip Render() when the dirty rect is empty, unless the backend requires a continuous redraw. By this point dirtyRectInPx already covers invalidated and scrolled-in rows, the cursor's old and new position, and a recreated backend or swap chain. The backends only extend it further for rows in invalidatedRows, and a non-empty invalidatedRows always yields a non-empty dirty rect in StartPaint, so without a custom shader the skipped frames are exactly the ones _present() would have dropped. Custom shaders (the retro effect included) mark the whole target dirty during Render() instead. Those that use the time variable have to run every frame and are excluded via RequiresContinuousRedraw(); the rest produce the same output for the same text texture, so skipping them changes nothing on screen. Measured on an Intel UHD 770 as the default adapter: a hidden-cursor workload with the pane at the bottom leaked ~120 MB/min of private commit on 1.24.11911.0 and stays flat with this change; an always-dirty control workload behaves the same with and without it. Closes microsoft#20342
Teera (teeramusic)
force-pushed
the
fix/gh20342-skip-unpresentable-frames
branch
from
September 11, 2026 22:48
998b654 to
7f65089
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary of the Pull Request
AtlasEngine::Present()currently runs the backend'sRender()on every frame and only afterwards, in_present(), decides that a frame with an empty dirty rect must not be presented. Rendering a frame that is then never presented makes some drivers hold on to the intermediate resources indefinitely, so private commit grows without bound for as long as such frames keep coming. This PR skipsRender()when the dirty rect is empty, unless the backend requires a continuous redraw.References and Relevant Issues
Closes #20342
#20346 tried to address the same issue by ignoring UIA output notifications; this PR fixes it in the renderer instead, where the unpresentable frame is produced.
Detailed Description of the Pull Request / Additional comments
Empty-dirty-rect frames are produced continuously in two everyday situations:
Cursor::_RedrawCursorschedules a frame whenever the cursor is "on" (blink phase), regardless of its visibility, whileRenderer::_invalidateCurrentCursoronly invalidates a visible cursor. So a hidden cursor that moves yields a frame with nothing dirty even when the pane is at the bottom. Claude Code (Ink) is one such TUI, which is why this leak also shows up without scrolling.The check is placed after
_handleSettingsUpdate()/_handleSwapChainUpdate()and before_b->Render(_p). At that point_p.dirtyRectInPxalready accounts for everything that can be dirty: invalidated and scrolled-in rows (StartPaint), the cursor's old and new position including a cursor that was just turned off (PaintCursor/EndPaint), and a recreated backend or swap chain (MarkAllAsDirty). The only places the backends extend the dirty rect duringRender()are gated oninvalidatedRows.contains(y)(glyph overhang inBackendD3D/BackendD2D), and a non-emptyinvalidatedRowsalways produces a non-empty dirty rect inStartPaint. The exception is the custom-shader path (_executeCustomShader, also used byexperimental.retroTerminalEffect), which sets the full target rect on everyRender(). So with a custom shader active, the current code presents a full frame every time one is scheduled even when nothing changed, and this PR skips those. That is safe when the shader doesn't read thetimevariable (the built-in retro shader declares it but never uses it): its output depends only on the text texture, which hasn't changed, so the last presented frame is already correct. Shaders that do usetimehave to run every frame;RequiresContinuousRedraw()reports exactly those and they are excluded from the early return. Without a custom shader, the skipped frames are precisely the ones_present()would have dropped. In every case the on-screen result is unchanged.The simpler variant from the issue thread (unconditionally returning when the dirty rect is empty) was tried first and dropped: it freezes time-based custom shaders.
Validation Steps Performed
1.24.11911.0and used as my default terminal for regular work since (Windows 11 26200, display on an Intel UHD 770 as the primary DXGI adapter). Private commit has stayed flat where the Store build previously grew to tens of GB.WindowsTerminal.exeprivate commit:CSI ?25l, cursor moved 20x/s withCSI n G, no text, pane at the bottom, unfocused): baseline grew ~120 MB/min linearly; with this change it stayed at 69 MB for the duration.PR Checklist
Per the AI usage policy: this was worked out and written with AI assistance (Claude Code). I have read the complete change and tested the built application as described above.