LoginSignup
157
135

More than 3 years have passed since last update.

Windows の 3 種類のスクリーンキャプチャ API を検証する

Last updated at Posted at 2021-01-19

Screen Capture API

現在 Windows には画面をキャプチャする方法が 3 通りくらい用意されている。一番速いのはどれなのか?それぞれ検証してみたい。(他にも非公開の API があるらしいが、ここでは触れない)
なお、記事中のソースは説明のため色々省いている。一通り動作するものはこちら。
https://github.com/i-saint/ScreenCaptureTest

GDI

石器時代の API だが、今でも機能するしお手軽。
注意すべき点として、GetSystemMetrics() や GetWindowRect() を使う場合、Visual Studio 上のプロジェクトの設定、"Manifest Tool" にある "DPI Awareness" を "Per Monitor High DPI Aware" に設定しておくべきである。これを怠ると、該当 API で得たウィンドウサイズやスクリーン座標がディスプレイの拡大率分縮小されたものになってしまう。

// 画面全体をキャプチャ
bool CaptureEntireScreen(const std::function<void(const void* data, int width, int height)>& callback)
{
    int x = ::GetSystemMetrics(SM_XVIRTUALSCREEN);
    int y = ::GetSystemMetrics(SM_YVIRTUALSCREEN);
    int width = ::GetSystemMetrics(SM_CXVIRTUALSCREEN);
    int height = ::GetSystemMetrics(SM_CYVIRTUALSCREEN);

    BITMAPINFO info{};
    info.bmiHeader.biSize = sizeof(info.bmiHeader);
    info.bmiHeader.biWidth = width;
    info.bmiHeader.biHeight = height;
    info.bmiHeader.biPlanes = 1;
    info.bmiHeader.biBitCount = 32;
    info.bmiHeader.biCompression = BI_RGB;
    info.bmiHeader.biSizeImage = width * height * 4;

    bool ret = false;
    HDC hscreen = ::GetDC(nullptr); // null で画面全体指定となる
    HDC hdc = ::CreateCompatibleDC(hscreen);
    void* data = nullptr;
    if (HBITMAP hbmp = ::CreateDIBSection(hdc, &info, DIB_RGB_COLORS, &data, NULL, NULL)) {
        ::SelectObject(hdc, hbmp);
        ::StretchBlt(hdc, 0, 0, width, height, hscreen, x, y, width, height, SRCCOPY);
        callback(data, width, height);
        ::DeleteObject(hbmp);
        ret = true;
    }
    ::DeleteDC(hdc);
    ::ReleaseDC(nullptr, hscreen);
    return ret;
}

// 指定のウィンドウをキャプチャ
bool CaptureWindow(HWND hwnd, const std::function<void(const void* data, int width, int height)>& callback)
{
    RECT rect{};
    ::GetWindowRect(hwnd, &rect);
    int width = rect.right - rect.left;
    int height = rect.bottom - rect.top;

    BITMAPINFO info{};
    info.bmiHeader.biSize = sizeof(info.bmiHeader);
    info.bmiHeader.biWidth = width;
    info.bmiHeader.biHeight = height;
    info.bmiHeader.biPlanes = 1;
    info.bmiHeader.biBitCount = 32;
    info.bmiHeader.biCompression = BI_RGB;
    info.bmiHeader.biSizeImage = width * height * 4;

    bool ret = false;
    HDC hscreen = ::GetDC(hwnd);
    HDC hdc = ::CreateCompatibleDC(hscreen);
    void* data = nullptr;
    if (HBITMAP hbmp = ::CreateDIBSection(hdc, &info, DIB_RGB_COLORS, &data, NULL, NULL)) {
        ::SelectObject(hdc, hbmp);
        ::PrintWindow(hwnd, hdc, PW_RENDERFULLCONTENT);
        callback(data, width, height);
        ::DeleteObject(hbmp);
        ret = true;
    }
    ::DeleteDC(hdc);
    ::ReleaseDC(hwnd, hscreen);
    return ret;
}

// テスト
void main()
{
    CaptureEntireScreen([](const void* data, int w, int h) {
        // 処理
        });

    CaptureWindow(::GetForegroundWindow(), [](const void* data, int w, int h) {
        // 処理
        });
}

得られたピクセルデータは BGRA の並びで、かつ上下が反転している 点に注意。
BitBlt() を使用している例もよく見るが、マルチディスプレイ環境で画面全体を取れないことがあるっぽい (スクリーン座標が負になりうるとき) とか、chrome などのウィンドウをキャプチャできないとかの問題があるので避けた。

Desktop Duplication API

Desktop Duplication API
Windows 8 で加わった API。画面をテクスチャ (ID3D11Texture2D) として取得できるのが特徴。キャプチャはモニター単位であり、ウィンドウ単位でのキャプチャはサポートしていない。

class DesktopDuplication
{
public:
    using Callback = std::function<void(ID3D11Texture2D*, int w, int h)>;

    bool start(int monitor_index = 0);
    void stop();
    bool getFrame(int timeout_ms, const Callback& calback);

private:
    com_ptr<ID3D11Device> m_device;
    com_ptr<IDXGIOutputDuplication> m_duplication;
};

bool DesktopDuplication::start(int monitor_index)
{
    // デバイス作成
    ::D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, m_device.put(), nullptr, nullptr);

    // OutputDuplication 作成
    com_ptr<IDXGIDevice> dxgi;
    com_ptr<IDXGIAdapter> adapter;
    com_ptr<IDXGIOutput> output;
    com_ptr<IDXGIOutput1> output1;
    m_device->QueryInterface(IID_PPV_ARGS(dxgi.put()));
    dxgi->GetParent(IID_PPV_ARGS(adapter.put()));
    adapter->EnumOutputs(monitor_index, output.put());
    output->QueryInterface(IID_PPV_ARGS(output1.put()));
    output1->DuplicateOutput(m_device.get(), m_duplication.put());
    return true;
}

void DesktopDuplication::stop()
{
    m_duplication = nullptr;
}

bool DesktopDuplication::getFrame(int timeout_ms, const Callback& calback)
{
    bool ret = false;
    com_ptr<IDXGIResource> resource;
    DXGI_OUTDUPL_FRAME_INFO frame_info{};
    if (SUCCEEDED(m_duplication->AcquireNextFrame(timeout_ms, &frame_info, resource.put()))) {
        if (frame_info.LastPresentTime.QuadPart != 0) { // 空フレームでないかチェック
            com_ptr<ID3D11Texture2D> surface; // これがキャプチャ結果
            resource->QueryInterface(IID_PPV_ARGS(surface.put()));

            DXGI_OUTDUPL_DESC desc;
            m_duplication->GetDesc(&desc);

            calback(surface.get(), desc.ModeDesc.Width, desc.ModeDesc.Height);
            ret = true;
        }
        m_duplication->ReleaseFrame();
    }
    return ret;
}

// テスト
void main()
{
    bool arrived = false;
    auto callback = [&](ID3D11Texture2D* surface, int w, int h) {
        // surface に対して必要な処理を行う
        arrived = true;
    };

    DesktopDuplication duplication;
    if (duplication.start()) {
        while (!arrived) {
            duplication.getFrame(500, callback);
        }
        duplication.stop();
    }
}

IDXGIOutputDuplication を作り、モニタに結びつけ、AcquireNextFrame() でフレームが来るのを待つ。意外とシンプルである。
ただし、AcquireNextFrame() には気をつけるべき点があって、成功しても空のフレームが返ってくることがある。空の場合は frame_info.LastPresentTime.QuadPart が 0 になっており、これで判別する。空の場合でも ReleaseFrame() は呼ばないといけない。

得られたテクスチャは CPU read のフラグはないので、CPU 側に読みたい場合は staging texture を経由する必要がある。テクスチャのピクセルの並びは BGRA で固定のようだ。GDI と違い上下反転はしていない。

Windows Graphics Capture

Windows.Graphics.Capture
Windows 10 April 2018 Update (1803) で加わった API。Desktop Duplication API と同様に結果をテクスチャとして取れ、加えてウィンドウ単位 (HWND)、モニタ単位 (HMONITOR) での取得がサポートされている。素晴らしい。欲を言えばあと 10 年早く欲しかった!

WinRT の一部として提供されているため、使用する場合 C# っぽい API を使うことになる。とはいえ、素の D3D11 との interop も用意されており、必ずしも全てを WinRT 色に染める必要はない。ここではできるだけ素の D3D11 に寄った実装にする。

using namespace winrt;
using namespace winrt::Windows::Foundation;
using namespace winrt::Windows::System;
using namespace winrt::Windows::Graphics;
using namespace winrt::Windows::Graphics::DirectX;
using namespace winrt::Windows::Graphics::DirectX::Direct3D11;
using namespace winrt::Windows::Graphics::Capture;

class GraphicsCapture
{
public:
    using Callback = std::function<void(ID3D11Texture2D*, int w, int h)>;

    bool start(HWND hwnd, bool free_threaded, const Callback& callback);
    void stop();

private:
    void onFrameArrived(
        winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
        winrt::Windows::Foundation::IInspectable const& args);

private:
    Callback m_callback;
    com_ptr<ID3D11Device> m_device;

    IDirect3DDevice m_device_rt{ nullptr };
    Direct3D11CaptureFramePool m_frame_pool{ nullptr };
    GraphicsCaptureItem m_capture_item{ nullptr };
    GraphicsCaptureSession m_capture_session{ nullptr };
    Direct3D11CaptureFramePool::FrameArrived_revoker m_frame_arrived;
};

bool GraphicsCapture::start(HWND hwnd, bool free_threaded, const Callback& callback)
{
    m_callback = callback;

    // デバイス作成
    ::D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, m_device.put(), nullptr, nullptr);

    // WinRT 版デバイス作成
    auto dxgi = m_device.as<IDXGIDevice>();
    com_ptr<::IInspectable> device_rt;
    ::CreateDirect3D11DeviceFromDXGIDevice(dxgi.get(), device_rt.put());
    m_device_rt = device_rt.as<IDirect3DDevice>();

    // CaptureItem 作成
    auto factory = get_activation_factory<GraphicsCaptureItem>();
    auto interop = factory.as<IGraphicsCaptureItemInterop>();
    interop->CreateForWindow(hwnd, guid_of<ABI::Windows::Graphics::Capture::IGraphicsCaptureItem>(), put_abi(m_capture_item));
    if (m_capture_item) {
        // FramePool 作成
        auto size = m_capture_item.Size();
        if (free_threaded) // 専用スレッドからコールバックする方式
            m_frame_pool = Direct3D11CaptureFramePool::CreateFreeThreaded(m_device_rt, DirectXPixelFormat::B8G8R8A8UIntNormalized, 1, size);
        else // メインスレッドからコールバックする方式
            m_frame_pool = Direct3D11CaptureFramePool::Create(m_device_rt, DirectXPixelFormat::B8G8R8A8UIntNormalized, 1, size);
        m_frame_arrived = m_frame_pool.FrameArrived(auto_revoke, { this, &GraphicsCapture::onFrameArrived });

        // キャプチャ開始
        m_capture_session = m_frame_pool.CreateCaptureSession(m_capture_item);
        m_capture_session.StartCapture();
        return true;
    }
    else {
        return false;
    }
}

void GraphicsCapture::stop()
{
    m_frame_arrived.revoke();
    m_capture_session = nullptr;
    if (m_frame_pool) {
        m_frame_pool.Close();
        m_frame_pool = nullptr;
    }
    m_capture_item = nullptr;
    m_callback = {};
}

void GraphicsCapture::onFrameArrived(winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const& args)
{
    auto frame = sender.TryGetNextFrame();
    auto size = frame.ContentSize(); // ウィンドウのサイズ。テキスチャのサイズと一致するとは限らない

    com_ptr<ID3D11Texture2D> surface; // これがキャプチャ結果
    frame.Surface().as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>()->GetInterface(guid_of<ID3D11Texture2D>(), surface.put_void());

    m_callback(surface.get(), size.Width, size.Height);
}


// テスト
void main()
{
    // non-free threaded
    {
        GraphicsCapture capture;
        HWND target = ::GetForegroundWindow(); // 適当にアクティブなウィンドウをキャプチャ

        bool arrived = false;
        auto callback = [&](ID3D11Texture2D* surface, int w, int h) {
            // surface に対して必要な処理を行う
            arrived = true;
        };

        if (capture.start(target, false, callback)) {
            MSG msg;
            while (!arrived) {
                ::GetMessage(&msg, nullptr, 0, 0);
                ::TranslateMessage(&msg);
                ::DispatchMessage(&msg); // この中からコールバックが呼ばれる
            }
            capture.stop();
        }
    }

    // free threaded
    {
        GraphicsCapture capture;
        HWND target = ::GetForegroundWindow();

        std::mutex mutex;
        std::condition_variable cond;

        // キャプチャスレッドから呼ばれる
        auto callback = [&](ID3D11Texture2D* surface, int w, int h) {
            // surface に対して必要な処理を行う
            cond.notify_one(); // メインスレッドに完了を通知
        };

        std::unique_lock<std::mutex> lock(mutex);
        if (capture.start(target, true, callback)) {
            cond.wait(lock); // コールバック完了を待つ
            capture.stop();
        }
    }
}

WinRT と素の D3D11 が混ざっててキモいが、適当にインターフェースで隠蔽すれば他は WinRT を意識せずに済む。
デバイスを作成、キャプチャアイテムを作成、FramePool の作成、キャプチャ開始。フレームが来たらコールバックが呼ばれるのでその中でテクスチャに必要な処理を行う、というのが大雑把な流れ。

IGraphicsCaptureItemInterop::CreateForWindow() がウィンドウ指定キャプチャ、IGraphicsCaptureItemInterop::CreateForMonitor() がモニタ指定のキャプチャとなる。
Direct3D11CaptureFramePool::FrameArrived() でコールバックを登録、フレームが来たらそれが呼ばれる。気をつけるべきなのはコールバックが呼ばれるタイミングで、二通りの方式が用意されている。
一つは FramePool 作成元スレッドから呼ぶ方式で、Direct3D11CaptureFramePool::Create() で作成するとこちらになる。この場合、 フレームが来るのは DispatchMessage() の中 になる。よってこちらの方法を使う場合、たとえウィンドウがなくてもメッセージループが必要になる。
もう一つはキャプチャ専用のスレッドを走らせてそこからコールバックする方式で、Direct3D11CaptureFramePool::CreateFreeThreaded() で作成するとこちらになる。この場合、フレームが来たタイミングでキャプチャ用スレッドからコールバックが呼ばれる。
FramePool の終了や破棄は作成元スレッドから行わなければならない。CreateFreeThreaded() の時にコールバック内から終了させるといったことはできない。

キャプチャ結果のテクスチャのサイズは、ウィンドウのサイズよりちょっと大きめになるようだ。frame.ContentSize() がウィンドウのサイズで、手元の環境では常に一致していなかった。
ドキュメントによると、キャプチャ結果のテクスチャはコールバックを抜けたら手放すべきで、保持すべきではないとされている。保持したい場合はコピーを作成することになるだろう。
他の API と違い、ピクセルの並びは BGRA 以外にもできる (Direct3D11CaptureFramePool::Create() などの引数で指定)。また、Desktop Duplication API 同様キャプチャ結果テクスチャに CPU read フラグはない。

雑感

それぞれの方法でプライマリモニタを何度かキャプチャし、一番速い結果を挙げるとこうなった。

  • GDI - 54.18ms
  • Desktop Duplication API - 8.74ms
  • Windows Graphics Capture (non-free threaded) - 5.55ms
  • Windows Graphics Capture (free threaded) - 2.96ms

プライマリモニタは解像度 4k で、2k のサブモニタが一つ繋がっている環境。
Desktop Duplication API も Windows Graphics Capture も、初期化処理は含めていない (初期化は 100ms 前後)。キャプチャ結果テクスチャを staging テクスチャにコピーし、コピーの完了を待って Map() するまでの時間で測定している。
GDI 版は内部的にサブモニタを含む全画面をキャプチャしているはずなので不当に不利な結果になっているが、それにしても他 2 つの方が圧倒的に速い。また、Windows Graphics Capture は free threaded の方が常に速かった。

というわけで、新しい API ほど期待通り色々良くなっていそうだという感触を得られた。

157
135
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
157
135