UWP(유니버설 Windows 플랫폼) DirectX 게임의 입력 대기 시간 최적화

입력 대기 시간은 게임 플레이에 큰 영향을 미칠 수 있으며, 이를 최적화하면 게임이 더욱 세련되게 느껴질 수 있습니다. 또한 적절한 입력 이벤트 최적화로 배터리 수명을 향상시킬 수 있습니다. 올바른 CoreDispatcher 입력 이벤트 처리 옵션을 선택하여 게임이 입력을 가능한 한 원활하게 처리하도록 하는 방법을 알아봅니다.

입력 대기 시간

입력 대기 시간은 시스템이 사용자 입력에 응답하는 데 소요되는 시간입니다. 응답은 화면에 표시되는 내용 또는 오디오 피드백을 통해 수신되는 내용의 변경 사항인 경우가 많습니다.

터치 포인터, 마우스 포인터 또는 키보드에서 가져온 모든 입력 이벤트는 이벤트 처리기에서 처리하게 되는 메시지를 생성합니다. 최신 터치 디지타이저 및 게임 주변 장치는 포인터당 최소 100Hz의 입력 이벤트를 보고하며, 앱은 포인터(또는 키 입력)당 초당 100개 이상의 이벤트를 받을 수 있습니다. 여러 포인터가 동시 발생하거나 더 높은 정밀도 입력 디바이스(예: 게임 마우스)를 사용하는 경우 업데이트 속도가 증폭됩니다. 이벤트 메시지 큐는 매우 빠르게 채울 수 있습니다.

이벤트가 시나리오에 가장 적합한 방식으로 처리되도록 게임의 입력 대기 시간 요구를 파악하는 것이 중요합니다. 모든 게임에 대한 솔루션은 없습니다.

전원 효율성

입력 대기 시간의 컨텍스트에서 '전원 효율성'은 게임에서 GPU를 사용하는 양을 나타냅니다. GPU 리소스를 적게 사용하는 게임은 전원 효율성이 높고 배터리 수명이 길어질 수 있습니다. 이는 CPU에도 유효합니다.

게임이 사용자의 환경을 저하하지 않고 초당 60프레임 미만(현재 대부분의 디스플레이에서 최대 렌더링 속도)으로 전체 화면을 그릴 수 있는 경우, 그리는 빈도가 낮아지면 전원 효율성이 높아집니다. 일부 게임에서는 사용자 입력에 대한 응답으로 화면만 업데이트하므로 초당 60프레임에서 동일한 콘텐츠를 반복적으로 그리면 안 됩니다.

최적화 항목 선택

DirectX 앱을 디자인할 경우 몇 가지 선택을 해야 합니다. 매끄러운 애니메이션을 표시하려면 앱이 초당 60프레임 렌더링을 해야 합니까, 아니면 입력에 대한 응답으로만 렌더링해야 합니까? 가능 력 대기 시간이 가장 낮아야 합니까, 아니면 약간의 지연을 허용할 수 있습니까? 내 사용자가 내 앱의 배터리 사용량에 대해 신중할 것으로 예상합니까?

이러한 질문에 대한 답변은 다음 시나리오 중 하나에 맞게 앱을 정렬할 수 가능성이 있습니다.

  1. 온디맨드 렌더링. 이 범주의 게임은 특정 유형의 입력에 대한 응답으로 화면을 업데이트하기만 하면 됩니다. 앱이 동일한 프레임을 반복적으로 렌더링하지 않고 앱이 입력을 기다리는 데 대부분의 시간을 소비하기 때문에 입력 대기 시간이 짧기 때문에 전원 효율성이 뛰어났습니다. 보드 게임 및 뉴스 판독기는 이 범주에 속할 수 있는 앱의 예제입니다.
  2. 임시 애니메이션으로 요청 시 렌더링합니다. 이 시나리오는 특정 유형의 입력이 사용자의 후속 입력에 종속되지 않는 애니메이션을 시작한다는 점을 제외하고 첫 번째 시나리오와 유사합니다. 게임이 동일한 프레임을 반복적으로 렌더링하지 않고 게임에 애니메이션 효과를 주지 않는 동안 입력 대기 시간이 낮기 때문에 전원 효율성이 좋습니다. 이동마다 애니메이션 효과를 주는 대화형 어린이 게임 및 보드 게임은 이 범주에 속할 수 있는 앱의 예제입니다.
  3. 초당 60프레임 렌더링. 이 시나리오에서는 게임이 화면을 지속적으로 업데이트합니다. 디스플레이에 표시할 수 있는 최대 프레임 수를 렌더링하므로 전원 효율성이 낮아집니다. 콘텐츠가 표시되는 동안 DirectX가 스레드를 차단하기 때문에 입력 대기 시간이 높아집니다. 이렇게 하면 스레드가 사용자에게 표시할 수 있는 것보다 더 많은 프레임을 디스플레이에 보낼 수 없게 됩니다. 1인칭 슈팅 게임, 실시간 전략 게임, 물리 기반 게임은 이 범주에 속할 수 있는 앱의 예제입니다.
  4. 초당 60프레임을 렌더링하고 가능한 가장 낮은 입력 대기 시간을 달성합니다. 시나리오 3과 마찬가지로 앱은 화면을 지속적으로 업데이트하므로 전원 효율성이 저하됩니다. 게임이 별도의 스레드의 입력에 응답하여 그래픽을 디스플레이에 표시하여 입력 처리가 차단되지 않는다는 점이 다릅니다. 온라인 멀티 플레이어 게임, 격투 게임 또는 리듬/타이밍 게임은 매우 빠듯한 이벤트 기간 내에서 이동 입력을 지원하기 때문에 이 범주에 속할 수 있습니다.

구현

대부분의 DirectX 게임은 게임 루프라는 요소에 따라 구동됩니다. 기본 알고리즘은 사용자가 게임 또는 앱을 종료할 때까지 다음 단계를 수행하는 것입니다.

  1. 프로세스 입력
  2. 게임 상태 업데이트
  3. 게임 콘텐츠 그리기

DirectX 게임의 콘텐츠가 렌더링되고 화면에 표시될 준비가 될 경우, 게임 루프는 GPU가 새 프레임을 받을 준비가 될 때까지 대기한 후 다시 입력을 처리합니다.

간단한 직소 퍼즐 게임을 반복하여 이전에 언급한 각 시나리오에 대한 게임 루프의 구현을 보여 드리겠습니다. 각 구현과 논의된 의사 결정 지점, 이점, 장단점은 짧은 대기 시간 입력 및 전원 효율성을 위해 앱을 최적화하는 데 도움이 되는 가이드 역할을 할 수 있습니다.

시나리오 1: 주문형 렌더링

첫 번째 직소 퍼즐 게임 반복으로 사용자가 퍼즐 조각을 이동할 때만 화면을 업데이트합니다. 사용자는 퍼즐 조각을 제자리로 끌거나 퍼즐 조각을 선택한 다음 올바른 대상을 터치하여 제자리에 맞출 수 있습니다. 두 번째 사례에서 퍼즐 조각은 애니메이션이나 효과 없이 대상으로 이동합니다.

코드에는 CoreProcessEventsOption::ProcessOneAndAllPending을 사용하는 IFrameworkView::Run 메서드 내에 단일 스레드 게임 루프가 있습니다. 이 옵션을 사용하면 큐에서 현재 사용 가능한 모든 이벤트가 전달됩니다. 보류 중인 이벤트가 없을 경우, 게임 루프가 나타날 때까지 대기합니다.

void App::Run()
{
    
    while (!m_windowClosed)
    {
        // Wait for system events or input from the user.
        // ProcessOneAndAllPending will block the thread until events appear and are processed.
        CoreWindow::GetForCurrentThread()->Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessOneAndAllPending);

        // If any of the events processed resulted in a need to redraw the window contents, then we will re-render the
        // scene and present it to the display.
        if (m_updateWindow || m_state->StateChanged())
        {
            m_main->Render();
            m_deviceResources->Present();

            m_updateWindow = false;
            m_state->Validate();
        }
    }
}

시나리오 2: 임시 애니메이션을 사용하는 주문형 렌더링

두 번째 반복에서는 사용자가 퍼즐 조각을 선택한 다음 해당 조각의 올바른 대상을 터치할 때 대상에 도달할 때까지 화면에 애니메이션 효과를 주도록 게임이 수정됩니다.

이전과 마찬가지로 코드에는 ProcessOneAndAllPending을 사용하여 큐에 입력 이벤트를 전달하는 단일 스레드 게임 루프가 있습니다. 이제 애니메이션 중에 루프가 CoreProcessEventsOption::ProcessAllIfPresent를 사용하도록 변경되어 새 입력 이벤트를 기다리지 않는다는 점이 다릅니다. 보류 중인 이벤트가 없으면 ProcessEvents가 즉시 반환되고 앱이 애니메이션의 다음 프레임을 표시할 수 있습니다. 애니메이션이 완료되면 루프가 ProcessOneAndAllPending으로 다시 전환되어 화면 업데이트를 제한합니다.

void App::Run()
{

    while (!m_windowClosed)
    {
        // 2. Switch to a continuous rendering loop during the animation.
        if (m_state->Animating())
        {
            // Process any system events or input from the user that is currently queued.
            // ProcessAllIfPresent will not block the thread to wait for events. This is the desired behavior when
            // you are trying to present a smooth animation to the user.
            CoreWindow::GetForCurrentThread()->Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessAllIfPresent);

            m_state->Update();
            m_main->Render();
            m_deviceResources->Present();
        }
        else
        {
            // Wait for system events or input from the user.
            // ProcessOneAndAllPending will block the thread until events appear and are processed.
            CoreWindow::GetForCurrentThread()->Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessOneAndAllPending);

            // If any of the events processed resulted in a need to redraw the window contents, then we will re-render the
            // scene and present it to the display.
            if (m_updateWindow || m_state->StateChanged())
            {
                m_main->Render();
                m_deviceResources->Present();

                m_updateWindow = false;
                m_state->Validate();
            }
        }
    }
}

ProcessOneAndAllPendingProcessAllIfPresent 간의 전환을 지원하려면 앱이 애니메이션 효과를 주는지 알 수 있도록 상태를 추적해야 합니다. 직소 퍼즐 앱에서 GameState 클래스의 게임 루프 중에 호출할 수 있는 새 메서드를 추가하여 이 작업을 수행합니다. 게임 루프의 애니메이션 분기는 GameState의 새 업데이트 메서드를 호출하여 애니메이션 상태의 업데이트를 구동합니다.

시나리오 3: 초당 60프레임 렌더링

세 번째 반복에서 앱에서는 사용자가 퍼즐을 작업한 기간을 보여 주는 타이머를 표시합니다. 경과된 시간을 밀리초까지 표시하므로 디스플레이를 최신 상태로 유지하려면 초당 60프레임을 렌더링해야 합니다.

시나리오 1 및 2와 마찬가지로 앱에는 단일 스레드 게임 루프가 있습니다. 이 시나리오에서는 항상 렌더링되므로 처음 두 시나리오에서 수행한 것처럼 게임 상태의 변경 내용을 더 이상 추적할 필요가 없다는 점이 다릅니다. 따라서 이벤트 처리에 ProcessAllIfPresent를 기본적으로 사용할 수 있습니다. 보류 중인 이벤트가 없으면 ProcessEvents가 즉시 반환되고 다음 프레임을 렌더링합니다.

void App::Run()
{

    while (!m_windowClosed)
    {
        if (m_windowVisible)
        {
            // 3. Continuously render frames and process system events and input as they appear in the queue.
            // ProcessAllIfPresent will not block the thread to wait for events. This is the desired behavior when
            // trying to present smooth animations to the user.
            CoreWindow::GetForCurrentThread()->Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessAllIfPresent);

            m_state->Update();
            m_main->Render();
            m_deviceResources->Present();
        }
        else
        {
            // 3. If the window isn't visible, there is no need to continuously render.
            // Process events as they appear until the window becomes visible again.
            CoreWindow::GetForCurrentThread()->Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessOneAndAllPending);
        }
    }
}

이 접근법은 렌더링 시기를 결정하기 위해 추가 상태를 추적할 필요가 없기 때문에 게임을 작성하는 가장 쉬운 방법입니다. 타이머 간격에서 합리적인 입력 응답성과 함께 가능한 한 가장 빠른 렌더링을 달성합니다.

그러나 이러한 개발 용이성은 그에 따른 비용이 듭니다. 초당 60프레임으로 렌더링하면 주문형 렌더링보다 더 많은 전력이 사용됩니다. 게임이 모든 프레임에 표시되는 내용을 변경할 때 ProcessAllIfPresent를 사용하는 것이 가장 좋습니다. 또한 앱이 ProcessEvents 대신 디스플레이의 동기화 간격에서 게임 루프를 차단하기 때문에 입력 대기 시간이 16.7ms만큼 증가합니다. 큐가 프레임당 한 번만 처리되기 때문에 일부 입력 이벤트가 삭제될 수 있습니다(60Hz).

시나리오 4: 초당 60프레임 렌더링 및 가능한 한 가장 낮은 입력 대기 시간 달성

일부 게임은 시나리오 3에서 볼 수 있는 입력 대기 시간 증가를 무시하거나 보정할 수 있습니다. 그러나 낮은 입력 대기 시간이 게임 플레이와 플레이어 피드백 감각에 중요한 경우 초당 60프레임을 렌더링하는 게임은 별도의 스레드에서 입력을 처리해야 합니다.

네 번째 직소 퍼즐 게임 반복은 게임 루프에서 렌더링되는 입력 처리 및 그래픽을 별도의 스레드로 분할하여 시나리오 3을 기반으로 합니다. 각각에 대한 별도의 스레드가 있으면 그래픽 출력에 의해 입력이 지연되지 않습니다. 그러나 결과적으로 코드가 더 복잡해집니다. 시나리오 4에서 입력 스레드는 CoreProcessEventsOption::ProcessUntilQuit를 사용하여 ProcessEvents를 호출합니다. 그러면 새 이벤트를 대기하고 사용 가능한 모든 이벤트를 디스패치합니다. 창이 닫히거나 게임이 CoreWindow::Close를 호출할 때까지 이 동작을 계속합니다.

void App::Run()
{
    // 4. Start a thread dedicated to rendering and dedicate the UI thread to input processing.
    m_main->StartRenderThread();

    // ProcessUntilQuit will block the thread and process events as they appear until the App terminates.
    CoreWindow::GetForCurrentThread()->Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessUntilQuit);
}

void JigsawPuzzleMain::StartRenderThread()
{
    // If the render thread is already running, then do not start another one.
    if (IsRendering())
    {
        return;
    }

    // Create a task that will be run on a background thread.
    auto workItemHandler = ref new WorkItemHandler([this](IAsyncAction^ action)
    {
        // Notify the swap chain that this app intends to render each frame faster
        // than the display's vertical refresh rate (typically 60 Hz). Apps that cannot
        // deliver frames this quickly should set this to 2.
        m_deviceResources->SetMaximumFrameLatency(1);

        // Calculate the updated frame and render once per vertical blanking interval.
        while (action->Status == AsyncStatus::Started)
        {
            // Execute any work items that have been queued by the input thread.
            ProcessPendingWork();

            // Take a snapshot of the current game state. This allows the renderers to work with a
            // set of values that won't be changed while the input thread continues to process events.
            m_state->SnapState();

            m_sceneRenderer->Render();
            m_deviceResources->Present();
        }

        // Ensure that all pending work items have been processed before terminating the thread.
        ProcessPendingWork();
    });

    // Run the task on a dedicated high priority background thread.
    m_renderLoopWorker = ThreadPool::RunAsync(workItemHandler, WorkItemPriority::High, WorkItemOptions::TimeSliced);
}

Microsoft Visual Studio 2015의 DirectX 11 및 XAML 앱(유니버설 Windows) 템플릿은 게임 루프를 비슷한 방식으로 여러 스레드로 분할합니다. Windows::UI::Core::CoreIndependentInputSource 개체를 사용하여 입력 처리 전용 스레드를 시작하고 XAML UI 스레드와 독립적으로 렌더링 스레드를 만듭니다. 이러한 템플릿에 대한 자세한 내용은 템플릿에서 유니버설 Windows 플랫폼 및 DirectX 게임 프로젝트 생성을 참조하세요.

입력 대기 시간을 줄이는 추가 방법

대기 가능한 스왑 체인 사용

DirectX 게임은 사용자가 화면에 표시되는 내용을 업데이트하여 사용자 입력에 응답합니다. 60Hz 디스플레이에서 화면은 16.7ms(1초/60프레임)마다 새로 고쳐집니다. 그림 1에서는 초당 60프레임을 렌더링하는 앱의 VBlank(16.7ms 새로 고침 신호)를 기준으로 입력 이벤트에 대한 대략적인 수명 주기 및 응답을 보여 줍니다.

그림 1

figure 1 input latency in directx

Windows 8.1에서 DXGI는 스왑 체인에 대한 DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT 플래그를 도입했습니다. 이 플래그를 사용하면 앱이 Present 큐를 빈 상태로 유지하기 위해 추론을 구현하지 않고도 이 대기 시간을 쉽게 줄일 수 있습니다. 이 플래그를 사용하여 생성한 스왑 체인을 대기 가능한 스왑 체인이라고 합니다. 그림 2에서는 대기 가능한 스왑 체인을 사용할 때 입력 이벤트에 대한 대략적인 수명 주기 및 응답을 보여줍니다.

그림 2

figure2 input latency in directx waitable

이러한 다이어그램에서는 게임이 디스플레이의 새로 고침 속도에 정의된 16.7ms 예산 내에서 각 프레임을 렌더링하고 표시할 수 있는 경우 두 개의 전체 프레임으로 입력 대기 시간을 줄일 수 있다는 점을 확인할 수 있습니다. 퍼즐 샘플은 대기 가능 스왑 체인을 사용하며 다음을 호출하여 Present 큐 제한을 제어합니다. m_deviceResources->SetMaximumFrameLatency(1);