Open gexgd0419 opened 1 year ago
Thanks for the write up!
However, both methods can sometimes result in sizes off by 1 pixel compared to what the target process gets using GetClientRect.
That's unpleasant. Maybe ceiling would be best in this situation? Better for the info to be there with the opportunity to crop rather than lose a row/column with no recourse. Not sure when anyone would get to this. This could also be a rabbit hole of edge cases, so I'm hesitant to touch this again myself, heh.
There WILL be a lot of edge cases, I guess.
I found this issue when I was trying to write a program to take screenshots of the client area of the foreground window, using functions like BitBlt
and PrintWindow
. I noticed that different DPI awareness will mess things up, so I first took the same route of copying the DPI awareness context of the target window. Then I found that the screenshot of a Control Panel window (opened when the primary monitor DPI is different) didn't get clipped correctly, thus finding the issue. And I haven't found a perfect solution for my program, either.
But here are the things I found:
Window positions and sizes will be aligned to their own logical coordinates.
When the actual DPI is 120 and the window is DPI unaware, you can't, for example, move the window to exactly physical coordinate (32,26). It will be put at physical coordinate (33,26) instead, because it's limited by the logical coordinate.
In contrast, when the window is system DPI aware, its system DPI is 120, but you change primary monitor DPI to 96 afterwards, two different logical coordinates can correspond to a same physical coordinate. Which means that, even a per-monitor aware program that can read physical coordinate cannot determine the precise logical coordinate of such a window, when the window DPI is larger than the actual DPI.
PhysicalToLogicalPointForPerMonitorDPI
failed in the left window, although I am just passing the "physical coordinates" obtained from GetWindowRect
to it. Also, in the right window, the "client area size" calculated from physical coordinates and DPIs is smaller in width by 1 pixel.
I think I just found a new way to get the client area dimensions.
Window DCs obtained by GetDC
have clipping regions set to the client area of the windows. You can use GetClipBox to retrieve the dimensions of the bounding rectangle of the clipping region.
You won't need to change your thread's DPI awareness context before calling GetClipBox
, as GetClipBox
uses the same coordinate as other operations on the same DC, such as BitBlt
.
The dimensions returned by GetClipBox
can be empty (0x0). In this case, there's a high chance that BitBlt won't work on this window, and will only capture a black image. Maybe this could be added to the logic that chooses between BitBlt and WGC automatically.
Now this logic only checks a list of window classes for windows that don't support BitBlt. As an example, Windows Terminal windows (window class Windows.UI.Composition.DesktopWindowContentBridge
) are not in the list, but they cannot be captured with BitBlt as well, and GetClipBox
on them returns empty RECTs.
DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED
:DPI virtualization will still be applied like DPI_AWARENESS_CONTEXT_UNAWARE
, but this will affect GDI rendering as well. Specifically, vector graphics and text will be rendered to the first integral multiple of 100% above the display scale factor, then scaled down by DWM to the actual scale factor.
I used code like this to capture a screenshot:
HDC hDC = GetDC(hwnd);
RECT rc;
GetClipBox(hDC, &rc);
HDC hMemDC = CreateCompatibleDC(hDC);
HBITMAP hBmp = CreateCompatibleBitmap(hDC, rc.right, rc.bottom);
SelectObject(hMemDC, hBmp);
BitBlt(hMemDC, 0, 0, rc.right, rc.bottom, hDC, 0, 0, SRCCOPY);
ReleaseDC(hwnd, hDC);
which will work on other windows, but not GDI scaled ones. The screenshot of GDI scaled windows are huge and crisp, but also cropped and incomplete.
Then I found that if you use CreateCompatibleBitmap
to create a bitmap compatible to a DC of a GDI scaled window, the bitmap you get will be the upscaled one. Such bitmaps can be then blited to the GDI scaled window, but when copied to clipboard, it will have upscaled content but with original dimensions (cropped).
If the bitmap is created to be compatible to other DCs such as the screen DC, or is created by other methods, you will get correctly downscaled images.
Calling
SetStretchBltMode(hMemDC, HALFTONE);
before BitBlt
will produce a better-looking image (the one on the right). Which might imply that in this case, BitBlt
actually performs StretchBlt
.
OBS does not have this issue when capturing GDI scaled windows. I guess it's using other methods to create its bitmap.
Operating System Info
Windows 11
Other OS
No response
OBS Studio Version
29.0.2
OBS Studio Version (Other)
No response
OBS Studio Log URL
None
OBS Studio Crash Log URL
No response
Expected Behavior
OBS can correctly get the capture area size of all windows in any DPI awareness context.
Current Behavior
If you use OBS to capture a system DPI aware window via BitBlt, and the target process has a different system DPI value than the OBS process, this line
https://github.com/obsproject/obs-studio/blob/e9ef38e3d38e08bffcabbb59230b94baa41ede96/plugins/win-capture/window-capture.c#L610-L611
will fail with ERROR_INVALID_PARAMETER and the return value will be NULL.
So the following
GetClientRect()
will still use the current DPI awareness context to calculate the coordinates, leading to an incorrect capture area size.Steps to Reproduce
Anything else we should know?
When using BitBlt to capture a window, OBS tries to apply the DPI awareness context of the target window to its own thread, so that it can get the correct coordinates for the target window DC.
https://github.com/obsproject/obs-studio/blob/e9ef38e3d38e08bffcabbb59230b94baa41ede96/plugins/win-capture/window-capture.c#L604-L612
This works most of the time, when the target process is DPI unaware or is per-monitor DPI aware.
However, if the target process is system DPI aware, and the target process has a different system DPI value than OBS, Windows will refuse to apply the system-aware context with a different system DPI, possibly because system DPI value is a per-process setting (see GetSystemDpiForProcess). You can still get the DPI value the target window is using, however, with GetDpiForWindow.
(Each process can have different system DPI values if you allow Windows to "fix scaling for apps" automatically. If that is enabled, the system DPI value of a process will be the DPI of the primary monitor when the process started, rather than when the user logged in, so that users can restart system DPI aware programs to fix their blurriness after changing the primary monitor DPI)
Here's some other possible ways to calculate the correct coordinates.
x = MulDiv(x, targetDpi, monitorDpi);
However, both methods can sometimes result in sizes off by 1 pixel compared to what the target process gets using GetClientRect.
WGC is generally better and doesn't have this issue, but for windows that support BitBlt, BitBlt actually copies the bitmap that hasn't been stretched by DWM, so it can capture crisp images of DPI unaware windows though they are still blurry on screen.