obsproject / obs-studio

OBS Studio - Free and open source software for live streaming and screen recording
https://obsproject.com
GNU General Public License v2.0
58.76k stars 7.82k forks source link

Incorrect capture area size when capturing a system DPI aware process with a different system DPI than the OBS process #8706

Open gexgd0419 opened 1 year ago

gexgd0419 commented 1 year ago

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

  1. Launch a system DPI aware program whose windows can be captured with BitBlt.
  2. Change the DPI value of the primary monitor (by changing the display settings or by switching to another monitor).
  3. Launch OBS and capture the window with window source via BitBlt.
  4. The captured area for the window will either be smaller or larger than the actual client area.

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.

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.

jpark37 commented 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.

gexgd0419 commented 1 year ago

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

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

gexgd0419 commented 1 year ago

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.

https://github.com/obsproject/obs-studio/blob/bad13c90e0ace0afefa6144e29a720d4db9dd692/plugins/win-capture/window-capture.c#L110-L155

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.

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

image

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.

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