Optimización de la latencia de entrada para juegos directX de Plataforma universal de Windows (UWP)
La latencia de entrada puede afectar significativamente a la experiencia de un juego y optimizarla puede hacer que un juego se sienta más pulido. Además, la optimización adecuada de eventos de entrada puede mejorar la duración de la batería. Aprende a elegir las opciones adecuadas de procesamiento de eventos de entrada de CoreDispatcher para asegurarte de que tu juego controla la entrada lo más fluida posible.
Latencia de entrada
La latencia de entrada es el tiempo que tarda el sistema en responder a la entrada del usuario. La respuesta suele ser un cambio en lo que se muestra en la pantalla o lo que se escucha a través de los comentarios de audio.
Cada evento de entrada, ya sea procedente de un puntero táctil, puntero del mouse o teclado, genera un mensaje que va a procesar un controlador de eventos. Digitalizadores táctiles modernos y periféricos de juegos notifican eventos de entrada a un mínimo de 100 Hz por puntero, lo que significa que las aplicaciones pueden recibir 100 eventos o más por segundo por puntero (o pulsación de tecla). Esta velocidad de actualizaciones se amplifica si se producen varios punteros simultáneamente o se usa un dispositivo de entrada de mayor precisión (por ejemplo, un mouse de juego). La cola de mensajes de eventos puede llenarse muy rápidamente.
Es importante comprender las demandas de latencia de entrada del juego para que los eventos se procesen de una manera que sea mejor para el escenario. No hay ninguna solución para todos los juegos.
Eficiencia energética
En el contexto de la latencia de entrada, "eficiencia energética" hace referencia a cuánto usa un juego la GPU. Un juego que usa menos recursos de GPU es más eficaz y permite una mayor duración de la batería. Esto también es cierto para la CPU.
Si un juego puede dibujar toda la pantalla a menos de 60 fotogramas por segundo (actualmente, la velocidad máxima de representación en la mayoría de las pantallas) sin degradar la experiencia del usuario, será más eficaz para poder dibujar con menos frecuencia. Algunos juegos solo actualizan la pantalla en respuesta a la entrada del usuario, por lo que esos juegos no deben dibujar el mismo contenido repetidamente en 60 fotogramas por segundo.
Elección de lo que se va a optimizar
Al diseñar una aplicación DirectX, debe tomar algunas opciones. ¿La aplicación necesita representar 60 fotogramas por segundo para presentar una animación suave o solo necesita representarse en respuesta a la entrada? ¿Necesita tener la latencia de entrada más baja posible o puede tolerar un poco de retraso? ¿Mis usuarios esperarán que mi aplicación sea prudente sobre el uso de la batería?
Las respuestas a estas preguntas probablemente alinearán la aplicación con uno de los escenarios siguientes:
- Representar a petición. Los juegos de esta categoría solo necesitan actualizar la pantalla en respuesta a tipos específicos de entrada. La eficiencia energética es excelente porque la aplicación no representa fotogramas idénticos repetidamente y la latencia de entrada es baja porque la aplicación pasa la mayor parte de su tiempo esperando la entrada. Los juegos de mesa y los lectores de noticias son ejemplos de aplicaciones que podrían caer en esta categoría.
- Representar a petición con animaciones transitorias. Este escenario es similar al primer escenario, excepto que determinados tipos de entrada iniciarán una animación que no depende de la entrada posterior del usuario. La eficiencia energética es buena porque el juego no representa fotogramas idénticos repetidamente y la latencia de entrada es baja mientras el juego no se anima. Los juegos infantiles interactivos y los juegos de mesa que animan cada movimiento son ejemplos de aplicaciones que podrían caer en esta categoría.
- Representar 60 fotogramas por segundo. En este escenario, el juego actualiza constantemente la pantalla. La eficiencia energética es deficiente porque representa el número máximo de fotogramas que puede presentar la pantalla. La latencia de entrada es alta porque DirectX bloquea el subproceso mientras se presenta el contenido. Si lo hace, evita que el subproceso envíe más fotogramas a la pantalla de lo que puede mostrar al usuario. Los tiradores de primera persona, los juegos de estrategia en tiempo real y los juegos basados en física son ejemplos de aplicaciones que podrían caer en esta categoría.
- Representar 60 fotogramas por segundo y lograr la latencia de entrada más baja posible. De forma similar al escenario 3, la aplicación actualiza constantemente la pantalla, por lo que la eficiencia energética será deficiente. La diferencia es que el juego responde a la entrada en un subproceso independiente, por lo que el procesamiento de entrada no está bloqueado mediante la presentación de gráficos en la pantalla. Los juegos multijugador en línea, los juegos de lucha o los juegos de ritmo/tiempo pueden caer en esta categoría porque admiten entradas de movimiento dentro de ventanas de eventos extremadamente ajustadas.
Implementación
La mayoría de los juegos directX están controlados por lo que se conoce como bucle de juego. El algoritmo básico consiste en realizar estos pasos hasta que el usuario salga del juego o la aplicación:
- Entrada de proceso
- Actualizar el estado del juego
- Dibujar el contenido del juego
Cuando el contenido de un juego DirectX se representa y está listo para presentarse a la pantalla, el bucle del juego espera hasta que la GPU esté lista para recibir un nuevo fotograma antes de reactivarse para procesar la entrada de nuevo.
Mostraremos la implementación del bucle del juego para cada uno de los escenarios mencionados anteriormente mediante la iteración en un sencillo juego de rompecabezas. Los puntos de decisión, las ventajas y los inconvenientes descritos con cada implementación pueden servir como guía para optimizar las aplicaciones para lograr una baja latencia de entrada y eficiencia energética.
Escenario 1: Representación a petición
La primera iteración del juego de puzzle jigsaw solo actualiza la pantalla cuando un usuario mueve una pieza de rompecabezas. Un usuario puede arrastrar una pieza de rompecabezas en su lugar o ajustarla a su lugar seleccionandola y tocando el destino correcto. En el segundo caso, la pieza del rompecabezas saltará al destino sin animaciones ni efectos.
El código tiene un bucle de juego de un solo subproceso dentro del método IFrameworkView::Run que usa CoreProcessEventsOption::P rocessOneAndAllPending. Con esta opción se envían todos los eventos disponibles actualmente en la cola. Si no hay ningún evento pendiente, el bucle del juego espera hasta que aparezca 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();
}
}
}
Escenario 2: Representación a petición con animaciones transitorias
En la segunda iteración, el juego se modifica para que cuando un usuario seleccione una pieza de rompecabezas y luego toque el destino correcto para esa pieza, anima a través de la pantalla hasta que llegue a su destino.
Como antes, el código tiene un bucle de juego de un solo subproceso que usa ProcessOneAndAllPending para enviar eventos de entrada en la cola. La diferencia ahora es que durante una animación, el bucle cambia para usar CoreProcessEventsOption::P rocessAllIfPresent para que no espere a nuevos eventos de entrada. Si no hay ningún evento pendiente, ProcessEvents devuelve inmediatamente y permite que la aplicación presente el siguiente fotograma en la animación. Una vez completada la animación, el bucle vuelve a ProcessOneAndAllPending para limitar las actualizaciones de pantalla.
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();
}
}
}
}
Para admitir la transición entre ProcessOneAndAllPending y ProcessAllIfPresent, la aplicación debe realizar un seguimiento del estado para saber si está animando. En la aplicación de rompecabezas, lo haces agregando un nuevo método al que se puede llamar durante el bucle del juego en la clase GameState. La rama de animación del bucle del juego controla las actualizaciones en el estado de la animación llamando al nuevo método Update de GameState.
Escenario 3: Representar 60 fotogramas por segundo
En la tercera iteración, la aplicación muestra un temporizador que muestra al usuario cuánto tiempo ha estado trabajando en el rompecabezas. Dado que muestra el tiempo transcurrido hasta el milisegundo, debe representar 60 fotogramas por segundo para mantener la pantalla actualizada.
Como en los escenarios 1 y 2, la aplicación tiene un bucle de juego de un solo subproceso. La diferencia con este escenario es que, dado que siempre se representa, ya no es necesario realizar un seguimiento de los cambios en el estado del juego tal como se hizo en los dos primeros escenarios. Como resultado, puede usar ProcessAllIfPresent de forma predeterminada para procesar eventos. Si no hay ningún evento pendiente, ProcessEvents devuelve inmediatamente y continúa para representar el siguiente fotograma.
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);
}
}
}
Este enfoque es la manera más fácil de escribir un juego porque no es necesario realizar un seguimiento del estado adicional para determinar cuándo representar. Logra la representación más rápida posible junto con una capacidad de respuesta de entrada razonable en un intervalo de temporizador.
Sin embargo, esta facilidad de desarrollo viene con un precio. La representación en 60 fotogramas por segundo usa más potencia que la representación a petición. Es mejor usar ProcessAllIfPresent cuando el juego cambia lo que se muestra cada fotograma. También aumenta la latencia de entrada hasta 16,7 ms porque la aplicación ahora bloquea el bucle de juego en el intervalo de sincronización de la pantalla en lugar de en ProcessEvents. Es posible que se quiten algunos eventos de entrada porque la cola solo se procesa una vez por fotograma (60 Hz).
Escenario 4: Representar 60 fotogramas por segundo y lograr la latencia de entrada más baja posible
Algunos juegos pueden ignorar o compensar el aumento de la latencia de entrada que se ve en el escenario 3. Sin embargo, si la latencia de entrada baja es fundamental para la experiencia del juego y el sentido de los comentarios del jugador, los juegos que representan 60 fotogramas por segundo necesitan procesar la entrada en un subproceso independiente.
La cuarta iteración del juego de rompecabezas se basa en el escenario 3 dividiendo el procesamiento de entrada y la representación de gráficos del bucle del juego en subprocesos independientes. Tener subprocesos independientes para cada uno garantiza que la entrada nunca se retrase por la salida de gráficos; sin embargo, el código se vuelve más complejo como resultado. En el escenario 4, el subproceso de entrada llama a ProcessEvents con CoreProcessEventsOption::P rocessUntilQuit, que espera nuevos eventos y envía todos los eventos disponibles. Continúa este comportamiento hasta que se cierra la ventana o el juego llama a 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);
}
La plantilla DirectX 11 y aplicación XAML (Windows universal) de Microsoft Visual Studio 2015 divide el bucle del juego en varios subprocesos de forma similar. Usa el objeto Windows::UI::Core::CoreIndependentInputSource para iniciar un subproceso dedicado a controlar la entrada y también crea un subproceso de representación independiente del subproceso de interfaz de usuario XAML. Para obtener más información sobre estas plantillas, lea Crear un proyecto de juego de Plataforma universal de Windows y DirectX a partir de una plantilla.
Formas adicionales de reducir la latencia de entrada
Uso de cadenas de intercambio esperables
Los juegos directX responden a la entrada del usuario actualizando lo que el usuario ve en pantalla. En una pantalla de 60 Hz, la pantalla se actualiza cada 16,7 ms (1 segundo/60 fotogramas). La figura 1 muestra el ciclo de vida aproximado y la respuesta a un evento de entrada en relación con la señal de actualización de 16,7 ms (VBlank) para una aplicación que representa 60 fotogramas por segundo:
Figura 1
En Windows 8.1, DXGI introdujo la marca de DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT para la cadena de intercambio, lo que permite a las aplicaciones reducir fácilmente esta latencia sin necesidad de implementar heurística para mantener la cola presente vacía. Las cadenas de intercambio creadas con esta marca se conocen como cadenas de intercambio esperables. En la figura 2 se muestra el ciclo de vida aproximado y la respuesta a un evento de entrada cuando se usan cadenas de intercambio que se pueden esperar:
Figura 2
Lo que vemos en estos diagramas es que los juegos pueden reducir potencialmente la latencia de entrada en dos fotogramas completos si son capaces de representar y presentar cada fotograma dentro del presupuesto de 16,7 ms definido por la frecuencia de actualización de la pantalla. El ejemplo de rompecabezas de jigsaw usa cadenas de intercambio que se pueden esperar y controla el límite de cola actual llamando a: m_deviceResources->SetMaximumFrameLatency(1);