Skip to content

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

Open
Schogol wants to merge 1 commit into
carbonengine:mainfrom
Schogol:fix/throttle-when-occluded
Open

EDR-7560 - Throttle rendering when the window is occluded, not just minimized#34
Schogol wants to merge 1 commit 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.

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.
@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.

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.

3 participants