Skip to content

EDR-7560 - Throttle rendering when the window is occluded, not just minimized - #34

Open
Schogol wants to merge 3 commits into
carbonengine:mainfrom
Schogol:fix/throttle-when-occluded
Open

Schogol wants to merge 3 commits into
carbonengine:mainfrom
Schogol:fix/throttle-when-occluded

Conversation

@Schogol

@Schogol Schogol commented Jul 25, 2026 •

Copy link
Copy Markdown

The WINDOW_HIDDEN throttle (TriDevice::Throttle: 20ms sleep + ShouldSkipFrame, which renders ~1 frame in 50) is only engaged for a minimized window (WM_SIZE / SIZE_MINIMIZED). A window that is fully covered by another window is not minimized, so it falls through to the weaker WINDOW_OUT_OF_FOCUS path (10ms sleep, no frame-skip) and keeps rendering the full scene for a screen nobody can see.

Measured in-game with a rendered space-view on my machine (NVIDIA GeForce RTX 2080, 60 Hz display, V-Sync on), with the window fully covered by another window:

  • DX11: ~64 fps, ~2.3 ms/frame of active rendering
  • DX12: ~72 fps, ~3.0 ms/frame

A focused, visible client renders at 60 fps (V-Sync-capped). So with V-Sync on at 60 Hz the client spends more GPU on rendering while the window is not visible than while it is. A covered window bypasses the V-Sync cap and renders above it, so the throttle is effectively backwards for covered windows. A minimized window (only possible in Window mode), by contrast, correctly drops to ~1 rendered frame/sec (~0.5 ms/frame).

DXGI cannot supply the missing signal: DXGI_STATUS_OCCLUDED is not reported for flip-model swapchains (DXGI_SWAP_EFFECT_FLIP_DISCARD, mandatory on D3D12) under DWM composition - both Present() and Present(0, DXGI_PRESENT_TEST) return S_OK while the window is fully covered or minimized. Occlusion is therefore detected from window geometry.

Tr2MainWindow now polls occlusion at 4 Hz (WM_TIMER) and drives the existing WINDOW_HIDDEN throttle:

  • IsWindowOccluded() samples 5 points on the window (centre + 4 inset corners) and, at each, finds the topmost opaque top-level window; the window is hidden iff every point is covered, or it is minimized. See-through overlays (WS_EX_LAYERED / _TRANSPARENT / _TOOLWINDOW, e.g. the Alt+Tab switcher) are skipped so they are not counted as occluders.
  • Focus-gated: a focused window is on top and cannot be occluded, so it skips the scan and pays only a HasFocus() check; the EnumWindows scan runs only on a backgrounded client.
  • Started from window creation, so a client launched straight into the background is throttled immediately.

Result: a covered window now takes the same WINDOW_HIDDEN path as a minimized one, dropping active rendering to ~1 frame/sec while hidden, with no change when focused or visible-but-unfocused.

Disclaimer:
This issue was discovered by me on my local machine and I used Claude to investigate the cause and help me create this fix. I was able to build it fine against Python 3.12 but unable to test on a live client.
I did create a TrinityAL probe which confirmed the occlusion-detection to be working as expected.

This fix should be most noticeable by people running multiple clients on the same machine and lower the overall GPU usage.

@Schogol

Schogol commented Jul 25, 2026

Copy link
Copy Markdown
Author

MacOS already has occlusion-detection in Tr2MainWindow_MacOS.mm

void Tr2MainWindow::OnWindowOcclusionChanged_MacOS()
{
	auto window = (NSWindow*)GetWindowID();
	auto visible = ( window.occlusionState & NSWindowOcclusionStateVisible ) != 0;
	gTriDev->SetThrottling( TriDevice::WINDOW_HIDDEN, !visible );
}

@Schogol Schogol changed the title Throttle rendering when the window is occluded, not just minimized EDR-7560 - Throttle rendering when the window is occluded, not just minimized Jul 26, 2026
berkeleynerd pushed a commit to berkeleynerd/trinity that referenced this pull request Aug 9, 2026
@CCP-Cortege

Copy link
Copy Markdown
Member

Thanks for opening this! We've seen it and notified the relevant team. One of our engineers will take a look soon. We appreciate you taking the time to contribute, and we'll follow up here once someone has had a chance to review. Thanks for your patience in the meantime.

@ccpisheden

Copy link
Copy Markdown

Hi, @Schogol, sorry for the very late reply. We're still working on our open source processes here.

While the feature is nice, we're worried that only checking 5 points is not going to be accurate enough and that it might be inadvertently triggered for windows that are only partially occluded. I think we would be able to accept this if it was able to more accurately determine occlusion.

My suggestion would be to do semi-random sampling over time, and only determine that the window is occluded if no samples pass for a certain number of ticks. A well-distributed sequence of samples can be generated using the plastic ratio described here: http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/, although that link seems to be dead at the moment...

Initialization:

const float INVERSE_PHI = 1.0f / 1.32471795724474602596f;
float g1 = 0.5f;
float g2 = 0.5f;

To get the next position in the sequence:

//add phi and phi^2
g1 += INVERSE_PHI;
g2 += INVERSE_PHI*INVERSE_PHI;

//reduce down to the [0, 1) range, aka fract()
g1 -= floor(g1);
g2 -= floor(g2);

//scale by window size and truncate to int
int occlusionX = (int)(g1 * windowWidth);
int occlusionY = (int)(g2 * windowHeight);

If all previous samples (say 32+ or so) over some time have been determined to be occluded, it should be safe to assume that the window as a whole is occluded.

I would also like to suggest that you swap the nested loops. Currently, you loop through the points, then call EnumWindows() for each of the points to go through the windows. It would be more efficient to call EnumWindows() once, then check all sample points against each window, which would also allow you to early out sooner. Since each point would be so cheap to test that way, it would be possible to test more than 5 points each tick as well.

The WINDOW_HIDDEN throttle (TriDevice::Throttle: 20ms sleep + ShouldSkipFrame,
which renders ~1 frame in 50) is only engaged for a minimized window
(WM_SIZE / SIZE_MINIMIZED). A window that is fully *covered* by another window is
not minimized, so it falls through to the weaker WINDOW_OUT_OF_FOCUS path (10ms
sleep, no frame-skip) and keeps rendering the full scene for a screen nobody can
see.

Measured in-game with a rendered space-view on my machine (NVIDIA GeForce RTX
2080, 60 Hz display, V-Sync on), with the window fully covered by another window:

  - DX11: ~64 fps, ~2.3 ms/frame of active rendering
  - DX12: ~72 fps, ~3.0 ms/frame

A focused, visible client renders at 60 fps (V-Sync-capped). So with V-Sync on at
60 Hz the client spends MORE GPU on rendering while the window is not visible than
while it is - a covered window bypasses the V-Sync cap and renders above it, so
the throttle is effectively backwards for covered windows. A minimized window, by
contrast, correctly drops to ~1 rendered frame/sec (~0.5 ms/frame).

DXGI cannot supply the missing signal: DXGI_STATUS_OCCLUDED is not reported for
flip-model swapchains (DXGI_SWAP_EFFECT_FLIP_DISCARD, mandatory on D3D12) under
DWM composition - both Present() and Present(0, DXGI_PRESENT_TEST) return S_OK
while the window is fully covered or minimized. Occlusion is therefore detected
from window geometry.

Tr2MainWindow now polls occlusion at 4 Hz (WM_TIMER) and drives the existing
WINDOW_HIDDEN throttle:

  - IsWindowOccluded() samples 5 points on the window (centre + 4 inset corners)
    and, at each, finds the topmost opaque top-level window; the window is hidden
    iff every point is covered, or it is minimized. See-through overlays
    (WS_EX_LAYERED / _TRANSPARENT / _TOOLWINDOW, e.g. the Alt+Tab switcher) are
    skipped so they are not counted as occluders.
  - Focus-gated: a focused window is on top and cannot be occluded, so it skips
    the scan and pays only a HasFocus() check; the EnumWindows scan runs only on
    a backgrounded client.
  - Started from window creation, so a client launched straight into the
    background is throttled immediately.

Result: a covered window now takes the same WINDOW_HIDDEN path as a minimized
one, dropping active rendering to ~1 frame/sec while hidden, with no change when
focused or visible-but-unfocused.
…tling

- Sample positions come from the R2 quasirandom sequence (plastic ratio)
  instead of 5 fixed points. The window counts as hidden only after 64
  consecutive covered samples (8 per 250ms tick, ~2s fully covered), and
  a single visible sample resets the run, so a partially visible window
  cannot be misdetected from one unlucky batch of points.
- One EnumWindows pass per tick collects the rects of opaque windows
  above us in Z-order (enumeration stops at our own window); sample
  points are then tested against the rect list with an early-out.
- Skip DWM-cloaked windows (suspended UWP apps keep a WS_VISIBLE
  top-level window that draws nothing) so they cannot count as occluders.
- Points outside every monitor still count as covered, preserving the
  off-screen behaviour of the previous topmost-window-at-point check.
- Minimized remains an immediate short-circuit so the poll result never
  fights the WM_SIZE/SIZE_MINIMIZED handler.
@Schogol
Schogol force-pushed the fix/throttle-when-occluded branch from 9bb85a8 to 3022a11 Compare October 11, 2026 01:50
@Schogol

Schogol commented Oct 11, 2026 •

Copy link
Copy Markdown
Author

Thanks for the review. Even though that linked website doesnt seem to exist anymore I was still
able to take a look at it using web.archive.org.
It gave me a bit of a headache trying to understand how this works and I think it's a complicated solution,
but it is definitely the more professional one compared to just sampling a set amount of points in
predetermined places.

Accumulating quasirandom sampling. Sample positions now come from the R2 sequence exactly as
you described, using the constant and recurrence from your snippet. The detector takes 8 samples
per 250ms tick and only reports the window hidden after 64 consecutive covered samples, so roughly
two seconds of the whole surface sampling as covered. A single visible sample resets the run, so
uncovering releases the throttle on the next tick. A partially occluded window can no longer be
misdetected from one unlucky batch of points. I used 64 samples because with 32 samples it sometimes
throttled the FPS when a small part of the window was still visible.

Loop swap. EnumWindows now runs once per tick and collects the rects of the opaque top-level
windows above ours in Z-order. Since the enumeration is top-first it stops at our own window,
because nothing below it can occlude us. Each sample point is then a handful of PtInRect tests
with an early-out on the first hit, which is what makes 8 points per tick essentially free.

Two accuracy additions in the same spirit:

  • DWM-cloaked windows are now skipped (DWMWA_CLOAKED). Suspended UWP apps, and every window on
    an inactive virtual desktop, keep a WS_VISIBLE top-level window that draws nothing; without
    this check they would count as occluders and could throttle a fully visible client.
  • A point that no occluder covers but that lies outside every monitor still counts as covered, so
    a window dragged or stranded off-screen still throttles.

The minimized path stays an IsIconic short-circuit ahead of sample accumulation, so it engages
immediately and never fights the existing WM_SIZE/SIZE_MINIMIZED handler. A focused window
still skips the scan entirely and resets the sample run, so the active client pays only a
HasFocus() check.

Measured on an RTX 2080, DX12, 60Hz, using the interactive TrinityALTest probe on
test/occlusion-throttle-probe, which now mirrors the shipping detection and logs state
transitions:

frame   6780 | visible  throttle=off | samples=16 | loopFps=     60.0 | renderFps=    60.0
frame   6810 | visible  throttle=off | samples=32 | loopFps=     60.0 | renderFps=    60.0
frame   6840 | visible  throttle=off | samples=48 | loopFps=     60.0 | renderFps=    60.0
== transition visible -> OCCLUDED (2.07s in previous state, coveredSamples=64)
frame   6900 | OCCLUDED throttle=ON  | samples=64 | loopFps=     32.2 | renderFps=     0.0
frame   6930 | OCCLUDED throttle=ON  | samples=64 | loopFps=     32.3 | renderFps=     1.1

Covering engages at 2.07s, and the rendered rate collapses to about 1 fps while the loop idles
at the 20ms throttle sleep. Uncovering releases on the next tick. Apartially covered window
never engages the throttle, with the sample run repeatedly reset by points landing on the visible
region. Minimizing engages immediately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants