Condividi tramite


Ottimizzare la latenza di input per i giochi DirectX (UWP) piattaforma UWP (Universal Windows Platform)

La latenza di input può influire significativamente sull'esperienza di un gioco e ottimizzarla può rendere un gioco più lucido. Inoltre, l'ottimizzazione corretta degli eventi di input può migliorare la durata della batteria. Scopri come scegliere le opzioni di elaborazione degli eventi di input CoreDispatcher appropriate per assicurarti che il tuo gioco gestisca l'input il più facilmente possibile.

Latenza dell'input

La latenza di input è il tempo necessario per il sistema per rispondere all'input dell'utente. La risposta è spesso un cambiamento di ciò che viene visualizzato sullo schermo o ciò che viene sentito tramite feedback audio.

Ogni evento di input, indipendentemente dal fatto che provenga da un puntatore virtuale, un puntatore del mouse o una tastiera, generi un messaggio da elaborare da un gestore eventi. I digitalizzatori di tocco moderni e le periferiche di gioco segnalano eventi di input almeno 100 Hz per puntatore, il che significa che le app possono ricevere 100 eventi o più al secondo per puntatore (o sequenza di tasti). Questa frequenza di aggiornamenti viene amplificata se si verificano più puntatori contemporaneamente o se viene usato un dispositivo di input con precisione maggiore (ad esempio, un mouse di gioco). La coda dei messaggi di evento può riempirsi molto rapidamente.

È importante comprendere le richieste di latenza di input del gioco in modo che gli eventi vengano elaborati in modo ottimale per lo scenario. Non c'è una soluzione per tutti i giochi.

Efficienza energetica

Nel contesto della latenza di input, "efficienza energetica" si riferisce alla quantità di utilizzo della GPU da parte di un gioco. Un gioco che usa meno risorse GPU è più efficiente dal livello di potenza e consente una maggiore durata della batteria. Questo vale anche per la CPU.

Se un gioco può disegnare l'intero schermo a meno di 60 fotogrammi al secondo (attualmente, la velocità massima di rendering nella maggior parte degli schermi) senza degradare l'esperienza dell'utente, sarà più efficiente a livello di potenza disegnando meno spesso. Alcuni giochi aggiornano lo schermo solo in risposta all'input dell'utente, quindi questi giochi non dovrebbero disegnare ripetutamente lo stesso contenuto a 60 fotogrammi al secondo.

Scelta di elementi da ottimizzare

Quando si progetta un'app DirectX, è necessario effettuare alcune scelte. L'app deve eseguire il rendering di 60 fotogrammi al secondo per presentare un'animazione uniforme o deve eseguire il rendering solo in risposta all'input? Deve avere la latenza di input più bassa possibile o può tollerare un po' di ritardo? Gli utenti si aspettano che l'app sia succosa sull'utilizzo della batteria?

Le risposte a queste domande saranno probabilmente allineate l'app con uno degli scenari seguenti:

  1. Eseguire il rendering su richiesta. I giochi in questa categoria devono solo aggiornare lo schermo in risposta a tipi di input specifici. L'efficienza energetica è eccellente perché l'app non esegue ripetutamente il rendering di fotogrammi identici e la latenza di input è bassa perché l'app impiega la maggior parte del tempo in attesa dell'input. Giochi da tavolo e lettori di notizie sono esempi di app che potrebbero rientrare in questa categoria.
  2. Eseguire il rendering su richiesta con animazioni temporanee. Questo scenario è simile al primo scenario, ad eccezione del fatto che determinati tipi di input avviano un'animazione che non dipende dall'input successivo dell'utente. L'efficienza energetica è buona perché il gioco non esegue ripetutamente il rendering di fotogrammi identici e la latenza di input è bassa mentre il gioco non anima. Giochi interattivi per bambini e giochi da tavolo che animano ogni mossa sono esempi di app che potrebbero rientrare in questa categoria.
  3. Eseguire il rendering di 60 fotogrammi al secondo. In questo scenario, il gioco aggiorna costantemente la schermata. L'efficienza energetica è scarsa perché esegue il rendering del numero massimo di fotogrammi che il display può presentare. La latenza di input è elevata perché DirectX blocca il thread durante la presentazione del contenuto. In questo modo si impedisce al thread di inviare più fotogrammi alla visualizzazione di quanto possa essere visualizzato all'utente. Sparatutto in prima persona, giochi di strategia in tempo reale e giochi basati sulla fisica sono esempi di app che potrebbero rientrare in questa categoria.
  4. Eseguire il rendering di 60 fotogrammi al secondo e ottenere la latenza di input più bassa possibile. Analogamente allo scenario 3, l'app aggiorna costantemente lo schermo, quindi l'efficienza energetica sarà scarsa. La differenza è che il gioco risponde all'input su un thread separato, in modo che l'elaborazione dell'input non venga bloccata dalla presentazione della grafica allo schermo. I giochi multiplayer online, i giochi di combattimento o il ritmo/temporizzazione potrebbero rientrare in questa categoria perché supportano gli input di spostamento all'interno di finestre di eventi estremamente strette.

Implementazione

La maggior parte dei giochi DirectX è guidata da ciò che è noto come ciclo di gioco. L'algoritmo di base consiste nell'eseguire questi passaggi fino a quando l'utente non chiude il gioco o l'app:

  1. Elabora input
  2. Aggiornare lo stato del gioco
  3. Disegnare il contenuto del gioco

Quando viene eseguito il rendering del contenuto di un gioco DirectX e pronto per essere presentato allo schermo, il ciclo del gioco attende fino a quando la GPU non è pronta a ricevere un nuovo fotogramma prima di riattivare l'input di elaborazione.

Mostreremo l'implementazione del ciclo di gioco per ognuno degli scenari menzionati in precedenza eseguendo un semplice puzzle game. I punti decisionali, i vantaggi e i compromessi discussi con ogni implementazione possono fungere da guida per ottimizzare le app per l'input a bassa latenza e l'efficienza energetica.

Scenario 1: Rendering su richiesta

La prima iterazione del gioco puzzle aggiorna lo schermo solo quando un utente sposta un pezzo di puzzle. Un utente può trascinare un pezzo di puzzle in posizione o agganciarlo in posizione selezionandolo e quindi toccando la destinazione corretta. Nel secondo caso, il pezzo puzzle passerà alla destinazione senza animazioni o effetti.

Il codice ha un ciclo di gioco a thread singolo all'interno del metodo IFrameworkView::Run che usa CoreProcessEventsOption::ProcessOneAndAllPending. L'uso di questa opzione invia tutti gli eventi attualmente disponibili nella coda. Se non sono presenti eventi in sospeso, il ciclo del gioco attende fino a quando non ne viene visualizzato uno.

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();
        }
    }
}

Scenario 2: Eseguire il rendering su richiesta con animazioni temporanee

Nella seconda iterazione, il gioco viene modificato in modo che quando un utente seleziona un pezzo di puzzle e quindi tocca la destinazione corretta per quel pezzo, si anima sullo schermo fino a raggiungere la destinazione.

Come in precedenza, il codice ha un ciclo di gioco a thread singolo che usa ProcessOneAndAllPending per inviare gli eventi di input nella coda. La differenza è che, durante un'animazione, il ciclo cambia per usare CoreProcessEventsOption::P rocessAllIfPresent in modo che non attenda nuovi eventi di input. Se non sono presenti eventi in sospeso, ProcessEvents restituisce immediatamente e consente all'app di presentare il fotogramma successivo nell'animazione. Al termine dell'animazione, il ciclo torna a ProcessOneAndAllPending per limitare gli aggiornamenti dello schermo.

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();
            }
        }
    }
}

Per supportare la transizione tra ProcessOneAndAllPending e ProcessAllIfPresent, l'app deve tenere traccia dello stato per sapere se sta animando. Nell'app puzzle, puoi farlo aggiungendo un nuovo metodo che può essere chiamato durante il ciclo del gioco nella classe GameState. Il ramo di animazione del ciclo di gioco aggiorna lo stato dell'animazione chiamando il nuovo metodo Update di GameState.

Scenario 3: Rendering di 60 fotogrammi al secondo

Nella terza iterazione, l'app visualizza un timer che mostra l'utente per quanto tempo sta lavorando al puzzle. Poiché visualizza il tempo trascorso fino al millisecondo, deve eseguire il rendering di 60 fotogrammi al secondo per mantenere aggiornato lo schermo.

Come negli scenari 1 e 2, l'app ha un ciclo di gioco a thread singolo. La differenza con questo scenario è che, poiché viene sempre eseguito il rendering, non è più necessario tenere traccia delle modifiche nello stato del gioco come è stato fatto nei primi due scenari. Di conseguenza, per impostazione predefinita può usare ProcessAllIfPresent per l'elaborazione degli eventi. Se non sono presenti eventi in sospeso, ProcessEvents restituisce immediatamente e continua a eseguire il rendering del fotogramma successivo.

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);
        }
    }
}

Questo approccio è il modo più semplice per scrivere un gioco perché non è necessario tenere traccia di uno stato aggiuntivo per determinare quando eseguire il rendering. Consente di ottenere il rendering più veloce possibile insieme alla velocità di risposta ragionevole dell'input in base a un intervallo timer.

Tuttavia, questa facilità di sviluppo viene fornita con un prezzo. Il rendering a 60 fotogrammi al secondo usa una potenza maggiore rispetto al rendering su richiesta. È consigliabile usare ProcessAllIfPresent quando il gioco cambia ciò che viene visualizzato ogni fotogramma. Aumenta anche la latenza di input di 16,7 ms perché l'app sta bloccando il ciclo di gioco sull'intervallo di sincronizzazione dello schermo anziché su ProcessEvents. Alcuni eventi di input potrebbero essere eliminati perché la coda viene elaborata una sola volta per fotogramma (60 Hz).

Scenario 4: Eseguire il rendering di 60 fotogrammi al secondo e ottenere la latenza di input più bassa possibile

Alcuni giochi possono essere in grado di ignorare o compensare l'aumento della latenza di input vista nello scenario 3. Tuttavia, se la latenza di input bassa è fondamentale per l'esperienza del gioco e il senso del feedback dei giocatori, i giochi che eseguono il rendering di 60 fotogrammi al secondo devono elaborare l'input su un thread separato.

La quarta iterazione del gioco puzzle si basa sullo scenario 3 suddividendo l'elaborazione di input e il rendering grafico dal ciclo di gioco in thread separati. La presenza di thread separati per ogni garantisce che l'input non venga mai ritardato dall'output grafico; tuttavia, il codice diventa più complesso di conseguenza. Nello scenario 4, il thread di input chiama ProcessEvents con CoreProcessEventsOption::P rocessUntilQuit, che attende nuovi eventi e invia tutti gli eventi disponibili. Continua questo comportamento finché la finestra non viene chiusa o il gioco chiama 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);
}

Il modello App DirectX 11 e XAML (Windows universale) in Microsoft Visual Studio 2015 suddivide il ciclo del gioco in più thread in modo simile. Usa l'oggetto Windows::UI::Core::CoreIndependentInputSource per avviare un thread dedicato alla gestione dell'input e crea anche un thread di rendering indipendente dal thread dell'interfaccia utente XAML. Per altri dettagli su questi modelli, vedere Creare un piattaforma UWP (Universal Windows Platform) e un progetto di gioco DirectX da un modello.

Altri modi per ridurre la latenza di input

Usare catene di scambio waitable

I giochi DirectX rispondono all'input dell'utente aggiornando ciò che l'utente vede sullo schermo. In uno schermo a 60 Hz, lo schermo viene aggiornato ogni 16,7 ms (1 secondo/60 fotogrammi). La figura 1 mostra il ciclo di vita approssimativo e la risposta a un evento di input rispetto al segnale di aggiornamento da 16,7 ms (VBlank) per un'app che esegue il rendering di 60 fotogrammi al secondo:

Figura 1

figura 1 latenza di input in directx

In Windows 8.1, DXGI ha introdotto il flag DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT per la catena di scambio, che consente alle app di ridurre facilmente questa latenza senza richiedere loro di implementare euristica per mantenere vuota la coda Presente. Le catene di scambio create con questo flag sono denominate catene di scambio in attesa. La figura 2 mostra il ciclo di vita approssimativo e la risposta a un evento di input quando si usano catene di scambio waitable:

Figura 2

figura2 la latenza di input in directx waitable

Ciò che vediamo da questi diagrammi è che i giochi possono potenzialmente ridurre la latenza di input di due fotogrammi completi se sono in grado di eseguire il rendering e presentare ogni fotogramma all'interno del budget di 16,7 ms definito dalla frequenza di aggiornamento dello schermo. L'esempio di puzzle usa catene di scambio waitable e controlla il limite di coda Presente chiamando: m_deviceResources->SetMaximumFrameLatency(1);