Otimizar a latência de entrada para jogos DirectX da Plataforma Universal do Windows (UWP)

A latência de entrada pode afetar significativamente a experiência de um jogo, e otimizá-la pode fazer com que um jogo pareça mais polido. Além disso, a otimização adequada do evento de entrada pode melhorar a vida útil da bateria. Saiba como escolher as opções corretas de processamento de eventos de entrada do CoreDispatcher para garantir que seu jogo lide com a entrada da maneira mais suave possível.

Latência de entrada

A latência de entrada é o tempo que leva para o sistema responder à entrada do usuário. A resposta geralmente é uma alteração no que é exibido na tela ou no que é ouvido por meio de feedback de áudio.

Cada evento de entrada, seja ele proveniente de um ponteiro de toque, ponteiro do mouse ou teclado, gera uma mensagem a ser processada por um manipulador de eventos. Os digitalizadores de toque modernos e os periféricos de jogos relatam eventos de entrada a um mínimo de 100 Hz por ponteiro, o que significa que os aplicativos podem receber 100 eventos ou mais por segundo por ponteiro (ou pressionamento de tecla). Essa taxa de atualizações é amplificada se vários ponteiros estiverem acontecendo simultaneamente ou se um dispositivo de entrada de maior precisão for usado (por exemplo, um mouse para jogos). A fila de mensagens de eventos pode ser preenchida muito rapidamente.

É importante entender as demandas de latência de entrada do seu jogo para que os eventos sejam processados da melhor maneira para o cenário. Não existe uma solução para todos os jogos.

Eficiência energética

No contexto da latência de entrada, "eficiência de energia" refere-se a quanto um jogo usa a GPU. Um jogo que usa menos recursos de GPU é mais eficiente em termos de energia e permite maior duração da bateria. Isso também vale para a CPU.

Se um jogo puder desenhar a tela inteira a menos de 60 quadros por segundo (atualmente, a velocidade máxima de renderização na maioria dos monitores) sem degradar a experiência do usuário, ele será mais eficiente em termos de energia desenhando com menos frequência. Alguns jogos só atualizam a tela em resposta à entrada do usuário, portanto, esses jogos não devem desenhar o mesmo conteúdo repetidamente a 60 quadros por segundo.

Escolhendo o que otimizar

Ao criar um aplicativo DirectX, você precisa fazer algumas escolhas. O aplicativo precisa renderizar 60 quadros por segundo para apresentar uma animação suave ou só precisa renderizar em resposta à entrada? Ele precisa ter a menor latência de entrada possível ou pode tolerar um pouco de atraso? Meus usuários esperam que meu aplicativo seja criterioso sobre o uso da bateria?

As respostas a essas perguntas provavelmente alinharão seu aplicativo a um dos seguintes cenários:

  1. Renderize sob demanda. Os jogos desta categoria só precisam atualizar a tela em resposta a tipos específicos de entrada. A eficiência de energia é excelente porque o aplicativo não renderiza quadros idênticos repetidamente e a latência de entrada é baixa porque o aplicativo passa a maior parte do tempo aguardando a entrada. Jogos de tabuleiro e leitores de notícias são exemplos de aplicativos que podem se enquadrar nessa categoria.
  2. Renderize sob demanda com animações transitórias. Esse cenário é semelhante ao primeiro cenário, exceto que determinados tipos de entrada iniciarão uma animação que não depende da entrada subsequente do usuário. A eficiência de energia é boa porque o jogo não renderiza quadros idênticos repetidamente e a latência de entrada é baixa enquanto o jogo não está animando. Jogos infantis interativos e jogos de tabuleiro que animam cada movimento são exemplos de aplicativos que podem se enquadrar nessa categoria.
  3. Renderize 60 quadros por segundo. Nesse cenário, o jogo está constantemente atualizando a tela. A eficiência de energia é baixa porque renderiza o número máximo de quadros que a tela pode apresentar. A latência de entrada é alta porque o DirectX bloqueia o thread enquanto o conteúdo está sendo apresentado. Isso impede que o thread envie mais quadros para a exibição do que pode mostrar ao usuário. Jogos de tiro em primeira pessoa, jogos de estratégia em tempo real e jogos baseados em física são exemplos de aplicativos que podem se enquadrar nessa categoria.
  4. Renderize 60 quadros por segundo e obtenha a menor latência de entrada possível. Semelhante ao cenário 3, o aplicativo está constantemente atualizando a tela, portanto, a eficiência de energia será ruim. A diferença é que o jogo responde à entrada em um thread separado, para que o processamento de entrada não seja bloqueado pela apresentação de gráficos na tela. Jogos multijogador online, jogos de luta ou jogos de ritmo/tempo podem se enquadrar nessa categoria porque suportam entradas de movimento em janelas de eventos extremamente apertadas.

Implementação

A maioria dos jogos DirectX é controlada pelo que é conhecido como loop de jogo. O algoritmo básico é executar estas etapas até que o usuário saia do jogo ou aplicativo:

  1. Entrada de processo
  2. Atualize o estado do jogo
  3. Desenhe o conteúdo do jogo

Quando o conteúdo de um jogo em DirectX é renderizado e está pronto para ser apresentado na tela, o loop do jogo aguarda até que a GPU esteja pronta para receber um novo quadro antes de acordar para processar a entrada novamente.

Mostraremos a implementação do loop do jogo para cada um dos cenários mencionados anteriormente, iterando em um jogo de quebra-cabeça simples. Os pontos de decisão, os benefícios e as compensações discutidos com cada implementação podem servir como um guia para ajudá-lo a otimizar seus aplicativos para entrada de baixa latência e eficiência de energia.

Cenário 1: Renderizar sob demanda

A primeira iteração do jogo de quebra-cabeça só atualiza a tela quando um usuário move uma peça do quebra-cabeça. Um usuário pode arrastar uma peça do quebra-cabeça para o lugar ou encaixá-la no lugar selecionando-a e tocando no destino correto. No segundo caso, a peça do quebra-cabeça saltará para o destino sem animação ou efeitos.

O código tem um loop de jogo de thread único dentro do método IFrameworkView::Run que usa CoreProcessEventsOption::P rocessOneAndAllPending. O uso dessa opção despacha todos os eventos atualmente disponíveis na fila. Se nenhum evento estiver pendente, o loop do jogo aguardará até que um apareça.

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

Cenário 2: Renderizar sob demanda com animações transitórias

Na segunda iteração, o jogo é modificado para que, quando um usuário seleciona uma peça do quebra-cabeça e toca o destino correto dessa peça, ela seja animada na tela até chegar ao seu destino.

Como antes, o código tem um loop de jogo de thread único que usa ProcessOneAndAllPending para expedir eventos de entrada na fila. A diferença agora é que, durante uma animação, o loop muda para usar CoreProcessEventsOption::P rocessAllIfPresent para que ele não aguarde novos eventos de entrada. Se nenhum evento estiver pendente, ProcessEvents retornará imediatamente e permitirá que o aplicativo apresente o próximo quadro na animação. Quando a animação é concluída, o loop volta para ProcessOneAndAllPending para limitar as atualizações de tela.

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 dar suporte à transição entre ProcessOneAndAllPending e ProcessAllIfPresent, o aplicativo deve acompanhar o estado para saber se ele está animando. No aplicativo de quebra-cabeça, você faz isso adicionando um novo método que pode ser chamado durante o loop do jogo na classe GameState. A ramificação de animação do loop do jogo gera atualizações no estado da animação chamando o novo método Update do GameState.

Cenário 3: Renderizar 60 quadros por segundo

Na terceira iteração, o aplicativo exibe um cronômetro que mostra ao usuário há quanto tempo ele está trabalhando no quebra-cabeça. Como ele exibe o tempo decorrido até o milissegundo, ele deve renderizar 60 quadros por segundo para manter a exibição atualizada.

Como nos cenários 1 e 2, o aplicativo tem um loop de jogo de thread único. A diferença com esse cenário é que, como ele está sempre renderizando, ele não precisa mais rastrear alterações no estado do jogo, como foi feito nos dois primeiros cenários. Como resultado, ele pode usar ProcessAllIfPresent por padrão para processar eventos. Se nenhum evento estiver pendente, ProcessEvents retornará imediatamente e continuará a renderizar o próximo quadro.

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

Essa abordagem é a maneira mais fácil de escrever um jogo porque não há necessidade de rastrear o estado adicional para determinar quando renderizar. Ele atinge a renderização mais rápida possível, juntamente com uma capacidade de resposta de entrada razoável em um intervalo de temporizador.

No entanto, essa facilidade de desenvolvimento tem um preço. A renderização a 60 quadros por segundo usa mais energia do que a renderização sob demanda. É melhor usar ProcessAllIfPresent quando o jogo está alterando o que é exibido a cada quadro. Ele também aumenta a latência de entrada em até 16,7 ms porque o aplicativo agora está bloqueando o loop do jogo no intervalo de sincronização da tela em vez de em ProcessEvents. Alguns eventos de entrada podem ser descartados porque a fila é processada apenas uma vez por quadro (60 Hz).

Cenário 4: renderizar 60 quadros por segundo e obter a menor latência de entrada possível

Alguns jogos podem ignorar ou compensar o aumento na latência de entrada visto no cenário 3. No entanto, se a baixa latência de entrada for crítica para a experiência do jogo e o senso de feedback do jogador, os jogos que renderizam 60 quadros por segundo precisam processar a entrada em um thread separado.

A quarta iteração do jogo de quebra-cabeça se baseia no cenário 3, dividindo o processamento de entrada e a renderização gráfica do loop do jogo em threads separados. Ter threads separados para cada um garante que a entrada nunca seja atrasada pela saída gráfica; No entanto, o código se torna mais complexo como resultado. No cenário 4, o thread de entrada chama ProcessEvents com CoreProcessEventsOption::P rocessUntilQuit, que aguarda novos eventos e despacha todos os eventos disponíveis. Ele continua esse comportamento até que a janela seja fechada ou o jogo chame 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);
}

O modelo DirectX 11 e Aplicativo XAML (Universal do Windows) no Microsoft Visual Studio 2015 divide o loop do jogo em vários threads de maneira semelhante. Ele usa o objeto Windows::UI::Core::CoreIndependentInputSource para iniciar um thread dedicado a manipular a entrada e também cria um thread de renderização independente do thread da interface do usuário XAML. Para obter mais detalhes sobre esses modelos, leia Criar um projeto de jogo DirectX e Plataforma Universal do Windows a partir de um modelo.

Maneiras adicionais de reduzir a latência de entrada

Usar cadeias de troca que podem ser esperadas

Os jogos DirectX respondem à entrada do usuário atualizando o que o usuário vê na tela. Em uma tela de 60 Hz, a tela é atualizada a cada 16,7 ms (1 segundo/60 quadros). A Figura 1 mostra o ciclo de vida aproximado e a resposta a um evento de entrada em relação ao sinal de atualização de 16,7 ms (VBlank) para um aplicativo que renderiza 60 quadros por segundo:

A figura 1

Figura 1 Latência de entrada no DirectX

No Windows 8.1, o DXGI introduziu o sinalizador DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT para a cadeia de troca, que permite que os aplicativos reduzam facilmente essa latência sem exigir que eles implementem heurísticas para manter a fila Presente vazia. As cadeias de troca criadas com esse sinalizador são chamadas de cadeias de troca de espera. A Figura 2 mostra o ciclo de vida aproximado e a resposta a um evento de entrada ao usar cadeias de troca esperáveis:

Figura 2

Figura 2 Latência de entrada no DirectX Waitable

O que vemos nesses diagramas é que os jogos podem reduzir a latência de entrada em dois quadros completos se forem capazes de renderizar e apresentar cada quadro dentro do orçamento de 16,7 ms definido pela taxa de atualização da tela. O exemplo de quebra-cabeça usa cadeias de troca de espera e controla o limite de fila atual chamando: m_deviceResources->SetMaximumFrameLatency(1);