Fixes #5360. Cull fully occluded overlapped opaque subviews during draw#5477
Conversation
…ing draw DrawSubViews() now skips drawing an Overlapped sibling when it is entirely covered by the higher-Z opaque peers already drawn. Such a sibling produces no visible output — every cell it would draw, including its own RenderLineCanvas, is clipped away by the clip "holes" those peers punched in DoDrawComplete — so skipping its Draw is output-neutral. The culled view's NeedsDraw is cleared (mirroring a drawn-but-fully-clipped pass) so it does not keep the SuperView perpetually dirty. Culling is intentionally conservative — it only applies to a candidate that is itself Overlapped, opaque (content/Border/Padding not Transparent), without a Margin shadow, and NOT SuperViewRendersLineCanvas. The last guard is what keeps Tabs correct: tab pages render their borders (and headers) through the SuperView's LineCanvas painters'-algorithm composition, so they must never be culled or their header line art would be dropped. The whole check is gated on the presence of an Overlapped sibling, keeping the common non-overlapping draw path zero-overhead. Opaque coverage is the region inside a view's (transparent-by-default) Margin — the Border frame — which matches the area DoDrawComplete excludes from the clip. Adds OcclusionCullingTests covering: fully-occluded cull, NeedsDraw clearing, partial coverage, transparent occluder, transparent candidate, shadowed candidate, SuperViewRendersLineCanvas candidate, non-Overlapped candidate, the no-Overlapped-siblings gate, output-neutrality, and a Tabs regression that inactive pages are never culled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cull path skips Draw()/DoDrawComplete(), which repopulates a view's CachedDrawnRegion for ViewportSettingsFlags.TransparentMouse layers. That region is invalidated by SetNeedsDraw(), so culling an opaque TransparentMouse view left it with a null cache and dropped it from mouse hit-testing (GetViewsUnderLocation blanket-removes TransparentMouse views with a null cache) — a state divergence from the drawn-but-clipped path the cull is meant to mirror. Add ParticipatesInTransparentMouseHitTesting() and exclude such candidates: the view's own ViewportSettings, plus any adornment that is TransparentMouse with non-empty Thickness (adornment caches survive SetNeedsDraw(), but the first draw must still populate a thick transparent-mouse adornment's cache). Margin is TransparentMouse by default, so this only excludes views with an actual Margin thickness — the common empty-margin overlapped view is still culled. Adds regression tests: TransparentMouseCandidate_IsNotCulled_AndKeepsHitRegion (fails without the guard — CachedDrawnRegion is null) and TransparentMouseMarginCandidate_IsNotCulled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR implements conservative occlusion culling in View.DrawSubViews() to skip drawing fully covered opaque Overlapped sibling views, reducing redundant draw work in stacked-overlap scenarios (e.g., tab pages, overlapped panels/windows). It tracks accumulated opaque screen coverage from higher-Z peers and clears NeedsDraw on culled views to prevent persistent dirty-state churn.
Changes:
- Add an occlusion-culling pass to
View.DrawSubViews()using aRegionof accumulated opaque coverage and a conservative set of guards (transparency, shadows, line-canvas composition, transparent-mouse hit-testing participation). - Introduce helpers in
View.Drawing.csto determine “opaque for occlusion”, transparent-mouse participation, and region-based “fully covered” testing. - Add a focused unit test suite validating culling behavior, safety guards, output-neutrality, and
Tabsregression coverage.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| Terminal.Gui/ViewBase/View.Drawing.cs | Adds region-based occlusion culling in DrawSubViews() plus helper methods to conservatively determine safe cull candidates. |
| Tests/UnitTestsParallelizable/ViewBase/Draw/OcclusionCullingTests.cs | Adds coverage for fully-occluded culling, safety guards (transparency, shadows, line-canvas, transparent mouse), and output-neutrality including Tabs regression. |
|
@harder what'd you think of the tuirec experience? |
@tig I like how easy it is to automate making screenshot videos now! I need to play with it more. I remember reading about it, but hadn't spent time trying it out yet until I was asking my agent to propose additional ways I could test and verify that this change didn't break any UI. It suggested using tuirec and proposed those 3 areas, then wired it all up. Very cool. |
Also, see the new-ish test infra that lets you provide Goldens as .ans files and then IDriver.StringToAnsi. |
Summary
DrawSubViews()now skips drawing an Overlapped sibling when it is entirely covered by the higher-Z opaque peers already drawn. A fully-covered sibling produces no visible output - every cell it would draw (including its ownRenderLineCanvas) is clipped away by the clip "holes" those peers punch inDoDrawComplete- so skipping itsDraw()is output-neutral. This is the occlusion-culling step of the #4973 series, taken now that the invalidation semantics are stable (#5357, #5358, #5359, #5433, #5434).The culled view's
NeedsDrawis cleared (mirroring a drawn-but-fully-clipped pass) so it does not keep the SuperView perpetually dirty.Approach
Regionof the screen area opaquely covered by higher-Z peers, accumulated as the existing reverse-Z (top-first) draw loop iterates.IsOpaqueForOcclusion(out Rectangle)reports whether a view fills a screen rectangle opaquely. The opaque region is the area inside the (transparent-by-default) Margin -Border.FrameToScreen()- matching exactly whatDoDrawCompleteexcludes from the clip.IsFullyCovered(rect, coverage)usesRegiondifference (not single-rectContains) so coverage spread across multiple peers is handled correctly.Overlappedsibling, so the common (non-overlapping) draw path is zero-overhead.Why it's conservative (safety guards)
A sibling is culled only when all of these hold. Each guard maps to a hazard called out in the issue:
OverlappedTransparent)ViewportSettings.Transparent- transparent views don't fully coverShadowStyle is None!SuperViewRendersLineCanvasTabscorrect: tab pages render their borders (and headers) through the SuperView's painters'-algorithm LineCanvas composition, so they must never be culledTabspages areSuperViewRendersLineCanvas, so they are deliberately not culled - preventing any header-line-art regression while still benefiting plain overlapped opaque siblings (overlapped Windows/panels, Arrangement-style stacks).Tests
Tests/UnitTestsParallelizable/ViewBase/Draw/OcclusionCullingTests.cs(11 tests, all green): fully-occluded cull,NeedsDrawclearing, partial coverage, transparent occluder, transparent candidate, shadowed candidate,SuperViewRendersLineCanvascandidate, non-Overlapped candidate, the no-Overlapped-siblings gate, output-neutrality, and aTabsregression asserting inactive pages are never culled.DrawCompleteevent counting (which fires iffDraw()was invoked), not justDriver.Contents.Visual & output-neutrality testing (tuirec + PTY)
Beyond the unit tests, I verified at the real-terminal layer (addressing the
Driver.Contents≠ IOutput caveat) usingScripts/tuirecrecordings andtmux capture-paneframe diffs on the scenarios most likely affected:SuperViewRendersLineCanvaspages (the guarded case)Method: built the app with and without the change, drove each scenario identically under a PTY, and diffed the settled screen grids. A control (capturing the same build twice) isolates the scenarios' own non-determinism.
Each before/after difference exactly equals that scenario's own run-to-run variance - culling adds nothing - and the regression-risk case (Tabs) is identical. Recordings:



To pull down this PR locally: