共用方式為


最佳化通用 Windows 平台 (UWP) DirectX 遊戲的輸入延遲

輸入延遲可能會大幅影響遊戲的體驗,而最佳化可能會讓遊戲感覺更完美。 此外,適當的輸入事件最佳化可以改善電池使用時間。 瞭解如何選擇正確的 CoreDispatcher 輸入事件處理選項,以確保您的遊戲盡可能順暢地處理輸入。

輸入延遲

輸入延遲是系統回應使用者輸入所需的時間。 回應通常是畫面上所顯示內容或透過音訊意見反應聽到的內容變更。

不論每個輸入事件都來自觸控指標、滑鼠指標或鍵盤,都會產生事件處理常式所要處理的訊息。 現代觸控數位板和遊戲周邊會報告每個指標至少 100 Hz 的輸入事件,這表示應用程式可以接收每個指標每秒 100 個事件或更多事件 (或按鍵輸入)。 如果同時發生多個指標,或使用較高的精確度輸入裝置 (例如遊戲滑鼠),就會放大此更新速率。 事件訊息佇列可以非常快速地填滿。

請務必瞭解遊戲的輸入延遲需求,以便以最適合案例的方式處理事件。 沒有一種解決方案適用於所有遊戲。

電源效率

在輸入延遲的內容中,「電源效率」是指遊戲使用 GPU 多少。 使用較少 GPU 資源的遊戲更有效率,並允許更長的電池使用時間。 這也適用於 CPU。

如果遊戲每秒可繪製不到 60 個畫面 (目前大部分顯示器的最大轉譯速度),而不會降低使用者體驗,則繪製頻率會比較低。 有些遊戲只會更新畫面以回應使用者輸入,因此這些遊戲不應該以每秒 60 個畫面重複繪製相同的內容。

選擇要最佳化的內容

設計 DirectX 應用程式時,您必須做出一些選擇。 應用程式需要每秒轉譯 60 個畫面才能呈現流暢動畫,還是只需要轉譯以回應輸入? 它是否需要有最低的輸入延遲,還是可以容忍一點點延遲? 我的使用者會期望我的應用程式明智地處理電池使用情況嗎?

這些問題的解答可能會讓您的應用程式符合下列其中一個案例:

  1. 視需要轉譯。 這個類別中的遊戲只需要更新畫面以回應特定類型的輸入。 電源效率非常出色,因為應用程式不會重複轉譯相同的畫面,而且輸入延遲很低,因為應用程式大部分時間都在等待輸入。 桌遊和新聞讀者是可能屬於此類別的應用程式範例。
  2. 使用暫時性動畫視需要轉譯。 此案例與第一個案例類似,不同之處在於特定類型的輸入會啟動不依賴於使用者後續輸入的動畫。 電源效率很好,因為遊戲不會重複轉譯相同的畫面,而且輸入延遲很低,而遊戲沒有動畫效果。 以動畫顯示每個移動的互動式兒童遊戲和桌遊是可能屬於此類別的應用程式範例。
  3. 每秒轉譯 60 個畫面。 在此案例中,遊戲會不斷更新畫面。 電源效率不佳,因為它會轉譯顯示畫面的最大畫面數。 輸入延遲很高,因為 DirectX 會在呈現內容時封鎖執行緒。 這麼做可防止執行緒將更多畫面傳送至顯示器,而不是向使用者顯示。 第一人稱射擊手、即時策略遊戲和以物理為基礎的遊戲是可能屬於此類別的應用程式範例。
  4. 每秒轉譯 60 個畫面,並達到最低的可能輸入延遲。 與案例 3 類似,應用程式會持續更新畫面,因此電源效率會很差。 差別在於遊戲會回應個別執行緒上的輸入,因此不會藉由將圖形呈現給顯示器來封鎖輸入處理。 線上多人遊戲、戰鬥遊戲或節奏/計時遊戲可能屬於此類別,因為它們支援在極其緊張的事件時段內移動輸入。

實作

大部分的 DirectX 遊戲是由所謂的遊戲迴圈所驅動。 基本演算法是執行這些步驟,直到使用者結束遊戲或應用程式為止:

  1. 處理輸入
  2. 更新遊戲狀態
  3. 繪製遊戲內容

當 DirectX 遊戲的內容呈現並準備好轉譯到畫面時,遊戲迴圈會等到 GPU 準備好接收新畫面,再喚醒以再次處理輸入。

我們將藉由逐一查看簡單的拼圖遊戲,來顯示稍早所述的每個案例的遊戲迴圈實作。 每個實作所討論的決策點、優點和取捨都可以做為指南,協助您將應用程式最佳化,以達到低延遲的輸入和電源效率。

案例 1:視需要轉譯

拼圖遊戲的第一個反覆項目只會在使用者移動拼圖時更新畫面。 使用者可以將拼圖拖曳就位,或藉由選取它並觸碰正確的目的地來貼齊它。 在第二個案例中,拼圖會跳到目的地,沒有動畫或效果。

程式碼在使用 CoreProcessEventsOption::ProcessOneAndAllPendingIFrameworkView::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 的新 Update 方法,以動畫的狀態驅動更新。

案例 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。 它也會增加高達 16.7 毫秒的輸入延遲,因為應用程式現在封鎖了顯示器同步間隔上的遊戲迴圈,而不是在 ProcessEvents 上。 某些輸入事件可能會遭到捨棄,因為每個畫面只會處理一次佇列 (60 Hz)。

案例 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 遊戲會更新使用者在畫面上看到的內容,以回應使用者輸入。 在 60 Hz 顯示器上,畫面每隔 16.7 毫秒重新整理一次 (1 秒/60 畫面)。 圖 1 顯示了每秒轉譯 60 個畫面的應用程式的大致生命週期,和相對於 16.7 毫秒重新整理訊號 (VBlank) 的輸入事件回應:

圖 1

圖 1 directx 中的輸入延遲

在 Windows 8.1 中,DXGI 為交換鏈結引入了 DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT 旗標,該旗標允許應用程式輕鬆減少此延遲,而無需它們實作啟發式方法以保持目前佇列為空。 使用此旗標建立的交換鏈結稱為可等候的交換鏈結。 圖 2 顯示使用可等候交換鏈結時的近似生命週期和輸入事件的回應:

圖 2

figure2 directx waitable 中的輸入延遲

我們從這些圖表中看到的是,如果遊戲能夠轉譯並呈現顯示器重新整理速率所定義的 16.7 毫秒預算內,遊戲可能會降低兩個完整畫面的輸入延遲。 拼圖範例會使用可等候的交換鏈結,並藉由呼叫 m_deviceResources->SetMaximumFrameLatency(1); 來控制目前的佇列限制: