Compartilhar via


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 o jogo pareça mais refinado. Além disso, a otimização adequada de eventos de entrada pode melhorar a vida útil da bateria. Saiba como escolher as opções corretas de processamento de evento de entrada do CoreDispatcher para garantir que o jogo manuseie a entrada da maneira mais suave possível.

Latência de entrada

A latência de entrada é o tempo necessário para que o sistema responda à entrada do usuário. A resposta geralmente é uma alteração no que é exibido na tela ou o que é ouvido por meio de comentários 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. Digitalizadores de toque modernos e periféricos de jogos relatam eventos de entrada a pelo menos 100 Hz por dispositivo de apontamento, o que significa que os aplicativos podem receber 100 eventos ou mais por segundo por dispositivo de apontamento (ou por tecla pressionada). Essa taxa de atualizações é amplificada se vários ponteiros estiverem sendo utilizados simultaneamente, ou se um dispositivo de entrada de alta precisão for usado (por exemplo, um mouse gamer). A fila de mensagens de evento pode ser preenchida muito rapidamente.

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

Eficiência de energia

No contexto de latência de entrada, "eficiência de energia" refere-se ao 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 em menos de 60 quadros por segundo (atualmente, a velocidade máxima de renderização na maioria das exibições) sem prejudicar a experiência do usuário, ele será mais eficiente ao desenhar 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 em 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 animação suave ou só precisa ser renderizado 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 com um dos seguintes cenários:

  1. Renderizar sob demanda. Os jogos nessa 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 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á sendo animado. 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 energética é ruim 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 a thread envie mais quadros para o display do que pode ser mostrado ao usuário. Os 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 às entradas em uma thread separada, de modo que o processamento das entradas não fique bloqueado ao exibir gráficos na tela. Jogos multijogador online, jogos de luta ou jogos de ritmo/tempo podem se enquadrar nessa categoria porque dão suporte a entradas de movimento dentro de janelas de eventos extremamente apertadas.

Implementação

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

  1. Processar entrada
  2. Atualizar o estado do jogo
  3. Desenhar o conteúdo do jogo

Quando o conteúdo de um jogo DirectX é renderizado e pronto para ser apresentado à tela, o loop de 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 de jogo para cada um dos cenários mencionados anteriormente, com base na iteração em um jogo de quebra-cabeça simples. Os pontos de decisão, benefícios e 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. Um usuário pode arrastar uma peça de quebra-cabeça para o lugar ou colocá-la no lugar selecionando-a e tocando no destino correto. No segundo caso, a peça do quebra-cabeça irá pular para o destino sem animação ou efeitos.

O código tem um loop de jogo em um único thread no método IFrameworkView::Run que usa CoreProcessEventsOption::ProcessOneAndAllPending. Usar essa opção despacha todos os eventos disponíveis atualmente na fila. Se nenhum evento estiver pendente, o loop do jogo aguardará até que um seja exibido.

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 de quebra-cabeça e, em seguida, toca no local correto para essa peça, ela se move animada pela tela até chegar ao seu destino.

Como antes, o código tem um loop de jogo de thread único que usa ProcessOneAndAllPending para expedir os 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 for concluída, o loop alterna de 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 monitorar o estado para saber se está em estado de animação. No aplicativo quebra-cabeça, você faz isso adicionando um novo método que pode ser chamado durante o *game loop* na classe GameState. O ramo de animação do ciclo do jogo impulsiona as 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 temporizador 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 única. A diferença com esse cenário é que, como ele está sempre renderizando, ele não precisa mais controlar as alterações no estado do jogo, como foi feito nos dois primeiros cenários. Como resultado, ele pode optar por usar ProcessAllIfPresent para processar eventos. Se nenhum evento estiver pendente, ProcessEvents retornará imediatamente e prosseguirá para 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 é necessário controlar o estado adicional para determinar quando renderizar. Ele realiza a renderização da maneira mais rápida possível, junto com uma resposta de entrada adequada em um intervalo de temporizador.

No entanto, essa facilidade de desenvolvimento vem com 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á mudando 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 exibição em vez de em ProcessEvents. Alguns eventos de entrada podem ser descartados porque a fila só é processada 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 ser capazes de ignorar ou compensar o aumento da latência de entrada visto no cenário 3. No entanto, se a baixa latência de entrada for fundamental para a experiência do jogo e o retorno do jogador, os jogos que renderizam 60 quadros por segundo precisam que o processamento de entrada ocorra em um thread separado.

A quarta iteração do jogo de quebra-cabeça baseia-se no cenário 3, dividindo o processamento de entrada e a renderização de gráficos 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 torna-se mais complexo como resultado. No cenário 4, a thread de entrada chama ProcessEvents com CoreProcessEventsOption::ProcessUntilQuit, que espera novos eventos e distribui 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 à manipulação de 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 uma Plataforma Universal do Windows e um projeto de jogo DirectX de um modelo.

Maneiras adicionais de reduzir a latência de entrada

Usar cadeias de troca aguardáveis

Os jogos DirectX respondem à entrada do usuário atualizando o que o usuário vê na tela. Em uma exibição de 60 Hz, a tela atualiza 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 relativo ao sinal de atualização de 16,7 ms (VBlank) para um aplicativo que renderiza 60 quadros por segundo:

Figura 1

figura 1 latência de entrada no DirectX

No Windows 8.1, o DXGI introduziu o sinalizador de DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT para a cadeia de troca, o que permite que os aplicativos reduzam facilmente essa latência sem precisar implementar heurísticas para manter a fila de Present vazia. As cadeias de troca criadas com esse sinalizador são conhecidas como cadeias de troca aguardadas. A Figura 2 mostra o ciclo de vida aproximado e a resposta a um evento de entrada quando se utilizam cadeias de troca aguardáveis.

Figura 2

latência de entrada no DirectX com espera na figura2 em

O que vemos nesses diagramas é que os jogos podem potencialmente 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 exibição. O exemplo de quebra-cabeça usa cadeias de troca com espera e controla o limite da fila de apresentação chamando: m_deviceResources->SetMaximumFrameLatency(1);