Optimiser la latence d’entrée pour les jeux DirectX plateforme Windows universelle (UWP)
La latence d’entrée peut avoir un impact significatif sur l’expérience d’un jeu et l’optimiser peut rendre un jeu plus poli. En outre, l’optimisation appropriée des événements d’entrée peut améliorer la durée de vie de la batterie. Découvrez comment choisir les options de traitement des événements d’entrée CoreDispatcher appropriées pour vous assurer que votre jeu gère les entrées aussi facilement que possible.
Latence d’entrée
La latence d’entrée est le temps nécessaire pour que le système réponde à l’entrée utilisateur. La réponse est souvent un changement dans ce qui s’affiche à l’écran, ou ce qui est entendu par le biais de commentaires audio.
Chaque événement d’entrée, qu’il provient d’un pointeur tactile, d’un pointeur de souris ou d’un clavier, génère un message à traiter par un gestionnaire d’événements. Les numériseurs tactiles modernes et les périphériques de jeu signalent les événements d’entrée à un minimum de 100 Hz par pointeur, ce qui signifie que les applications peuvent recevoir 100 événements ou plus par seconde par pointeur (ou frappe). Ce taux de mises à jour est amplifié si plusieurs pointeurs se produisent simultanément, ou si un appareil d’entrée de précision plus élevé est utilisé (par exemple, une souris de jeu). La file d’attente des messages d’événement peut se remplir très rapidement.
Il est important de comprendre les demandes de latence d’entrée de votre jeu afin que les événements soient traités de manière optimale pour le scénario. Il n’y a aucune solution pour tous les jeux.
Efficacité de l’alimentation
Dans le contexte de la latence d’entrée, l'« efficacité de l’alimentation » fait référence à la quantité d’un jeu qui utilise le GPU. Un jeu qui utilise moins de ressources GPU est plus efficace et permet une durée de vie plus longue de la batterie. Cela est également vrai pour l’UC.
Si un jeu peut dessiner l’écran entier à moins de 60 images par seconde (actuellement, la vitesse de rendu maximale sur la plupart des affichages) sans dégrader l’expérience de l’utilisateur, il sera plus efficace en dessinant moins souvent. Certains jeux mettent uniquement à jour l’écran en réponse à l’entrée utilisateur, de sorte que ces jeux ne doivent pas dessiner le même contenu à plusieurs reprises à 60 images par seconde.
Choix des éléments à optimiser pour
Lors de la conception d’une application DirectX, vous devez faire des choix. L’application doit-elle afficher 60 images par seconde pour présenter une animation fluide, ou doit-elle uniquement être rendue en réponse à l’entrée ? Doit-il avoir la latence d’entrée la plus faible possible ou peut-elle tolérer un peu de retard ? Mes utilisateurs s’attendent-ils à ce que mon application soit judicieuse sur l’utilisation de la batterie ?
Les réponses à ces questions aligneront probablement votre application avec l’un des scénarios suivants :
- Rendu à la demande. Les jeux de cette catégorie doivent uniquement mettre à jour l’écran en réponse à des types d’entrée spécifiques. L’efficacité de l’alimentation est excellente, car l’application ne restitue pas les images identiques à plusieurs reprises, et la latence d’entrée est faible, car l’application passe la plupart de son temps en attente d’entrée. Les jeux de bord et les lecteurs d’actualités sont des exemples d’applications qui peuvent tomber dans cette catégorie.
- Afficher à la demande avec des animations temporaires. Ce scénario est similaire au premier scénario, sauf que certains types d’entrée démarrent une animation qui ne dépend pas de l’entrée ultérieure de l’utilisateur. L’efficacité de l’alimentation est bonne, car le jeu n’affiche pas de trames identiques à plusieurs reprises, et la latence d’entrée est faible alors que le jeu n’est pas animé. Les jeux interactifs pour enfants et les jeux de tableau qui animent chaque déplacement sont des exemples d’applications qui peuvent tomber dans cette catégorie.
- Affichez 60 images par seconde. Dans ce scénario, le jeu met constamment à jour l’écran. L’efficacité de l’alimentation est médiocre, car elle affiche le nombre maximal d’images que l’affichage peut présenter. La latence d’entrée est élevée, car DirectX bloque le thread pendant que le contenu est présenté. Cela empêche le thread d’envoyer plus d’images à l’affichage qu’il ne peut afficher à l’utilisateur. Les tireurs de première personne, les jeux de stratégie en temps réel et les jeux basés sur la physique sont des exemples d’applications qui peuvent tomber dans cette catégorie.
- Affichez 60 images par seconde et obtenez la latence d’entrée la plus faible possible. Comme dans le scénario 3, l’application met constamment à jour l’écran, de sorte que l’efficacité de l’alimentation sera médiocre. La différence est que le jeu répond à l’entrée sur un thread distinct, de sorte que le traitement d’entrée n’est pas bloqué en présentant des graphiques à l’affichage. Les jeux multijoueurs en ligne, les jeux de combat ou les jeux de rythme/minutage peuvent tomber dans cette catégorie, car ils prennent en charge les entrées de déplacement dans des fenêtres d’événements extrêmement serrées.
Implémentation
La plupart des jeux DirectX sont pilotés par ce qu’on appelle la boucle de jeu. L’algorithme de base consiste à effectuer ces étapes jusqu’à ce que l’utilisateur quitte le jeu ou l’application :
- Entrée de processus
- Mettre à jour l’état du jeu
- Dessiner le contenu du jeu
Lorsque le contenu d’un jeu DirectX est rendu et prêt à être présenté à l’écran, la boucle de jeu attend que le GPU soit prêt à recevoir une nouvelle image avant de se réveiller pour traiter à nouveau l’entrée.
Nous allons montrer l’implémentation de la boucle de jeu pour chacun des scénarios mentionnés précédemment en itérant sur un jeu de puzzle jigsaw simple. Les points de décision, les avantages et les compromis abordés avec chaque implémentation peuvent servir de guide pour vous aider à optimiser vos applications pour une entrée à faible latence et l’efficacité de l’alimentation.
Scénario 1 : Rendu à la demande
La première itération du jeu de puzzle de jigsaw met uniquement à jour l’écran lorsqu’un utilisateur déplace une pièce de puzzle. Un utilisateur peut faire glisser une pièce de puzzle sur place ou l’aligner sur place en la sélectionnant, puis en touchant la destination correcte. Dans le deuxième cas, la pièce de puzzle saute à la destination sans animation ni effets.
Le code a une boucle de jeu à thread unique dans la méthode IFrameworkView ::Run qui utilise CoreProcessEventsOption ::P rocessOneAndAllPending. Cette option répartit tous les événements actuellement disponibles dans la file d’attente. Si aucun événement n’est en attente, la boucle de jeu attend qu’elle apparaisse.
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();
}
}
}
Scénario 2 : Rendu à la demande avec des animations temporaires
Dans la deuxième itération, le jeu est modifié afin que lorsqu’un utilisateur sélectionne une pièce de puzzle, puis touche la destination correcte pour cette pièce, il s’anime sur l’écran jusqu’à ce qu’il atteigne sa destination.
Comme précédemment, le code a une boucle de jeu à thread unique qui utilise ProcessOneAndAllPending pour distribuer des événements d’entrée dans la file d’attente. La différence est que lors d’une animation, la boucle change pour utiliser CoreProcessEventsOption ::P rocessAllIfPresent afin qu’elle n’attende pas de nouveaux événements d’entrée. Si aucun événement n’est en attente, ProcessEvents retourne immédiatement et permet à l’application de présenter l’image suivante dans l’animation. Une fois l’animation terminée, la boucle revient à ProcessOneAndAllPending pour limiter les mises à jour de l’écran.
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();
}
}
}
}
Pour prendre en charge la transition entre ProcessOneAndAllPending et ProcessAllIfPresent, l’application doit suivre l’état pour savoir s’il est animé. Dans l’application puzzle jigsaw, vous faites cela en ajoutant une nouvelle méthode qui peut être appelée pendant la boucle de jeu sur la classe GameState. La branche d’animation de la boucle de jeu génère des mises à jour dans l’état de l’animation en appelant la nouvelle méthode Update de GameState.
Scénario 3 : Afficher 60 images par seconde
Dans la troisième itération, l’application affiche un minuteur qui montre à l’utilisateur combien de temps il travaille sur le puzzle. Étant donné qu’il affiche le temps écoulé jusqu’à la milliseconde, il doit afficher 60 images par seconde pour maintenir l’affichage à jour.
Comme dans les scénarios 1 et 2, l’application a une boucle de jeu à thread unique. La différence avec ce scénario est que, parce qu’il est toujours rendu, il n’a plus besoin de suivre les modifications dans l’état du jeu, comme cela a été fait dans les deux premiers scénarios. Par conséquent, il peut par défaut utiliser ProcessAllIfPresent pour le traitement des événements. Si aucun événement n’est en attente, ProcessEvents retourne immédiatement et poursuit le rendu de l’image suivante.
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);
}
}
}
Cette approche est le moyen le plus simple d’écrire un jeu, car il n’est pas nécessaire de suivre l’état supplémentaire pour déterminer quand effectuer le rendu. Il atteint le rendu le plus rapide possible avec une réactivité d’entrée raisonnable à intervalle de minuteur.
Cependant, cette facilité de développement est fournie avec un prix. Le rendu à 60 images par seconde utilise plus de puissance que le rendu à la demande. Il est préférable d’utiliser ProcessAllIfPresent lorsque le jeu change ce qui est affiché chaque image. Elle augmente également la latence d’entrée jusqu’à 16,7 ms, car l’application bloque désormais la boucle de jeu sur l’intervalle de synchronisation de l’affichage au lieu de ProcessEvents. Certains événements d’entrée peuvent être supprimés, car la file d’attente n’est traitée qu’une seule fois par image (60 Hz).
Scénario 4 : Afficher 60 images par seconde et atteindre la latence d’entrée la plus faible possible
Certains jeux peuvent être en mesure d’ignorer ou de compenser l’augmentation de la latence d’entrée observée dans le scénario 3. Toutefois, si une faible latence d’entrée est essentielle à l’expérience du jeu et au sens des commentaires des joueurs, les jeux qui affichent 60 images par seconde doivent traiter les entrées sur un thread distinct.
La quatrième itération du jeu puzzle jigsaw repose sur le scénario 3 en fractionnant le traitement d’entrée et le rendu graphique de la boucle de jeu en threads distincts. Avoir des threads distincts pour chaque garantie que l’entrée n’est jamais retardée par la sortie graphique ; toutefois, le code devient plus complexe en conséquence. Dans le scénario 4, le thread d’entrée appelle ProcessEvents avec CoreProcessEventsOption ::P rocessUntilQuit, qui attend de nouveaux événements et répartit tous les événements disponibles. Il poursuit ce comportement jusqu’à ce que la fenêtre soit fermée ou que le jeu appelle 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);
}
Le modèle d’application DirectX 11 et XAML (Windows universel) dans Microsoft Visual Studio 2015 fractionne la boucle de jeu en plusieurs threads de la même manière. Il utilise l’objet Windows ::UI ::Core ::CoreIndependentInputSource pour démarrer un thread dédié à la gestion des entrées et crée également un thread de rendu indépendant du thread d’interface utilisateur XAML. Pour plus d’informations sur ces modèles, lisez Créer un projet de jeu plateforme Windows universelle et DirectX à partir d’un modèle.
Autres façons de réduire la latence d’entrée
Utiliser des chaînes d’échange pouvant être attendus
Les jeux DirectX répondent aux entrées utilisateur en mettant à jour ce que l’utilisateur voit à l’écran. Sur un affichage de 60 Hz, l’écran actualise toutes les 16,7 ms (1 seconde/60 images). La figure 1 montre le cycle de vie approximatif et la réponse à un événement d’entrée par rapport au signal d’actualisation 16,7 ms (VBlank) pour une application qui affiche 60 images par seconde :
La figure 1
Dans Windows 8.1, DXGI a introduit l’indicateur de DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT pour la chaîne d’échange, ce qui permet aux applications de réduire facilement cette latence sans qu’elles n’exigent qu’elles implémentent des heuristiques pour conserver la file d’attente Present vide. Les chaînes d’échange créées avec cet indicateur sont appelées chaînes d’échange pouvant être attendus. La figure 2 montre le cycle de vie approximatif et la réponse à un événement d’entrée lors de l’utilisation de chaînes d’échange pouvant être attendus :
Figure 2
Ce que nous voyons de ces diagrammes est que les jeux peuvent potentiellement réduire la latence d’entrée par deux images complètes s’ils sont capables de rendre et de présenter chaque image dans le budget de 16,7 ms défini par le taux d’actualisation de l’affichage. L’exemple de puzzle jigsaw utilise des chaînes d’échange pouvant être attendus et contrôle la limite de file d’attente Actuelle en appelant : m_deviceResources->SetMaximumFrameLatency(1);