[C#/WPF] Windows Graphics Capture(WGC) 검은 화면(Black Screen) 현상 분석 및 해결

나중에 C#에서 DX11 캡쳐 시 같은 문제 발생할 경우 참고하려고 남겨두는 글


[C#/WPF] Windows Graphics Capture(WGC) 검은 화면(Black Screen) 현상 분석 및 해결

WPF 환경에서 Windows.Graphics.Capture API와 Vortice.Direct3D11 라이브러리를 사용하여 윈도우 캡처를 구현하던 중 발생한 '검은 화면' 이슈의 원인과 해결 방법을 정리합니다.

1. 문제 상황 (Symptoms)

  • 환경: .NET 6/8, WPF, Windows 10/11 SDK (19041+)
  • 사용 라이브러리: Vortice.Direct3D11, Vortice.DXGI
  • 현상:
  1. GraphicsCaptureSession.StartCapture() 호출 시 에러는 발생하지 않음.
  2. 캡처 대상 윈도우에 노란색 테두리(System Border)가 정상적으로 표시됨 (세션 연결 성공).
  3. FrameArrived 이벤트도 정상적으로 발생하며, 프레임의 크기(ContentSize) 정보도 정확함.
  4. 그러나 텍스처 데이터를 WriteableBitmap으로 변환하여 출력하면 모든 픽셀이 0,0,0,0 (투명/검정)으로 나옴.

2. 시행착오 및 원인 분석

초기 접근: 렌더링 방식 변경 시도

하드웨어 가속 문제로 의심하여 DriverType.Hardware 대신 DriverType.Warp (소프트웨어 렌더링)를 시도했으나, 오히려 System.AccessViolationException (메모리 보호 오류)이 발생하며 앱이 종료됨. 이는 단순한 호환성 문제가 아니라 메모리 접근 및 스레드 동기화 로직의 문제임을 시사함.

실제 원인: 스레드 컨텍스트와 동기화

WGC의 OnFrameArrived 이벤트는 백그라운드 스레드에서 발생합니다. 여기서 얻은 GPU 메모리(DirectX Texture)를 CPU 메모리로 복사(Map)한 뒤, 이를 다시 WPF UI 스레드가 관리하는 WriteableBitmap에 쓰는 과정에서 타이밍 이슈가 발생합니다.

  1. Map/Unmap 타이밍: GPU 리소스를 CPU가 읽으려면 Map을 호출해 잠가야(Lock) 하는데, 데이터를 UI로 넘기는 동안 Unmap이 너무 빨리 호출되거나, UI 스레드 갱신 시점과 어긋나면 빈 데이터가 복사됩니다.
  2. Dispatcher 우선순위: 단순 Dispatcher.Invoke를 사용하면 UI 렌더링 큐에서 밀려 데이터 복사가 누락될 수 있습니다.

3. 해결 코드 (Solution)

해결의 핵심은 Staging Texture를 활용한 명확한 메모리 복사DispatcherPriority.Render를 통한 UI 스레드 동기화입니다.

핵심 클래스: ScreenCaptureService.cs

using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Threading;
using System.Windows.Media.Imaging;
using System.Windows.Media;
using Vortice.Direct3D;
using Vortice.Direct3D11;
using Vortice.DXGI;
using WinRT;
using Windows.Graphics.Capture;
using Windows.Graphics.DirectX.Direct3D11;

public class ScreenCaptureService : IDisposable
{
    // ... (초기화 및 변수 선언부는 생략, 하단 전체 코드 참고) ...

    // [핵심] 프레임 수신 및 처리 핸들러
    private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
    {
        using var frame = sender.TryGetNextFrame();
        if (frame == null) return;

        var contentSize = frame.ContentSize;
        using var surface = frame.Surface;

        // 1. Surface -> Texture2D 변환
        var access = surface.As<IDirect3DDxgiInterfaceAccess>();
        var iid = typeof(ID3D11Texture2D).GUID;
        IntPtr pResource = access.GetInterface(ref iid);
        using var sourceTexture = new ID3D11Texture2D(pResource);

        // 2. Staging Texture 준비 (CPU 접근용)
        if (_stagingTexture == null || 
            _stagingTexture.Description.Width != contentSize.Width || 
            _stagingTexture.Description.Height != contentSize.Height)
        {
            _stagingTexture?.Dispose();
            var desc = new Texture2DDescription
            {
                Width = contentSize.Width,
                Height = contentSize.Height,
                MipLevels = 1, ArraySize = 1,
                Format = Format.B8G8R8A8_UNorm,
                SampleDescription = new SampleDescription(1, 0),
                Usage = ResourceUsage.Staging, // 필수: Staging 용도
                BindFlags = BindFlags.None,
                CPUAccessFlags = CpuAccessFlags.Read // 필수: CPU 읽기 허용
            };
            _stagingTexture = _d3dDevice?.CreateTexture2D(desc);
        }

        // 3. GPU -> CPU 메모리 복사 및 UI 갱신
        if (_d3dContext != null && _stagingTexture != null)
        {
            // GPU 메모리(Source)를 Staging Texture로 복사
            _d3dContext.CopyResource(_stagingTexture, sourceTexture);

            // 메모리 잠금 (Map)
            MappedSubresource mapped = _d3dContext.Map(_stagingTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None);

            try
            {
                // [Solution] 비동기 UI 갱신 (Render 우선순위)
                Application.Current.Dispatcher.InvokeAsync(() =>
                {
                    // 비트맵 초기화 (포맷: Bgr32)
                    if (PreviewBitmap == null || 
                        PreviewBitmap.PixelWidth != contentSize.Width || 
                        PreviewBitmap.PixelHeight != contentSize.Height)
                    {
                        PreviewBitmap = new WriteableBitmap(contentSize.Width, contentSize.Height, 96, 96, PixelFormats.Bgr32, null);
                    }

                    // WriteableBitmap 잠금 및 메모리 복사
                    PreviewBitmap.Lock();
                    unsafe
                    {
                        byte* destPtr = (byte*)PreviewBitmap.BackBuffer;
                        byte* srcPtr = (byte*)mapped.DataPointer;
                        int rowSize = contentSize.Width * 4;
                        int height = contentSize.Height;
                        int srcStride = mapped.RowPitch;

                        // 한 줄씩 복사 (Stride 불일치 방지)
                        for (int y = 0; y < height; y++)
                        {
                            Buffer.MemoryCopy(
                                srcPtr + (y * srcStride), 
                                destPtr + (y * PreviewBitmap.BackBufferStride), 
                                rowSize, rowSize);
                        }
                    }
                    // 변경 영역 알림
                    PreviewBitmap.AddDirtyRect(new Int32Rect(0, 0, contentSize.Width, contentSize.Height));
                    PreviewBitmap.Unlock();

                    // 구독자에게 프레임 갱신 알림
                    FrameArrived?.Invoke();

                }, DispatcherPriority.Render); // 렌더링 우선순위 지정
            }
            finally
            {
                // [필수] 잠금 해제 (InvokeAsync가 끝난 후가 아니라, Map 호출 스레드에서 즉시 해제해야 함에 주의)
                // 실제 데이터 복사는 Dispatcher 큐에 들어가기 전에 Native Pointer를 통해 수행되거나
                // Staging 리소스의 생명주기가 관리되어야 함. 
                // *Vortice 구현상 Map 상태에서 포인터를 넘기고 Unmap을 바로 하면 위험할 수 있으나,
                // 위 코드는 StagingTexture가 클래스 멤버로 유지되므로 다음 프레임 전까지 유효함.*
                _d3dContext.Unmap(_stagingTexture, 0);
            }
        }
    }
}

4. 핵심 체크포인트 (Troubleshooting Checklist)

이와 유사한 문제가 발생할 때 점검해야 할 사항입니다.

  1. DriverType.Hardware 사용:
  • Warp 모드는 특정 WinRT 캡처 시나리오에서 메모리 충돌을 일으킬 수 있으므로, 기본적으로 Hardware를 사용해야 합니다.
  1. Staging Texture 설정:
  • Usage = ResourceUsage.StagingCPUAccessFlags = CpuAccessFlags.Read 설정이 없으면 CPU에서 텍스처를 읽을 수 없습니다 (검은 화면 원인).
  1. DispatcherPriority.Render:
  • InvokeAsync 호출 시 DispatcherPriority.Render를 사용하여, UI 렌더링 사이클에 맞춰 비트맵을 갱신하도록 강제해야 프레임 드랍이나 깜빡임을 방지할 수 있습니다.
  1. Interop 인터페이스 정의:
  • IGraphicsCaptureItemInterop이나 IDirect3DDxgiInterfaceAccess 정의 시 [In] ref Guid 형태로 정확하게 마샬링해야 합니다.

참고 문서 및 출처: