优化 通用 Windows 平台 (UWP) DirectX 游戏的输入延迟

输入延迟可能会显著影响游戏的体验,优化它可以使游戏感觉更加完美。 此外,适当的输入事件优化可以提高电池使用时间。 了解如何选择正确的 CoreDispatcher 输入事件处理选项,以确保游戏尽可能顺利地处理输入。

输入延迟

输入延迟是系统响应用户输入所需的时间。 响应通常是屏幕上显示的内容或通过音频反馈听到的内容的变化。

每个输入事件(无论是来自触摸指针、鼠标指针还是键盘)都会生成由事件处理程序处理的消息。 现代触摸数字化器和游戏外围设备至少报告每个指针 100 Hz 的输入事件,这意味着应用可以每秒接收 100 个事件或更多事件(或击键)。 如果多个指针同时发生,或者使用了更精确的输入设备(例如游戏鼠标),则会放大此更新速率。 事件消息队列可以快速填充。

请务必了解游戏的输入延迟需求,以便以最适合方案的方式处理事件。 所有游戏都没有任何解决方案。

电源效率

在输入延迟的上下文中,“电源效率”是指游戏使用 GPU 的数量。 使用较少 GPU 资源的游戏更省电,可延长电池使用时间。 这也适用于 CPU。

如果游戏每秒可以绘制不到 60 帧的整个屏幕(目前,大多数显示器上的最大呈现速度),而不会降低用户体验,那么通过绘制频率会提高效率。 某些游戏仅更新屏幕以响应用户输入,因此这些游戏不应以每秒 60 帧重复绘制相同的内容。

选择要优化的内容

设计 DirectX 应用时,需要做出一些选择。 应用是否需要每秒渲染 60 帧来呈现平滑动画,或者它只需要呈现以响应输入? 它是否需要尽可能低的输入延迟,或者可以容忍一点点延迟? 我的用户是否会期望我的应用对电池使用情况有谨慎?

这些问题的解答可能会使你的应用与以下方案之一保持一致:

  1. 按需呈现。 此类别中的游戏只需更新屏幕才能响应特定类型的输入。 电源效率非常出色,因为应用不会重复呈现相同的帧,并且输入延迟较低,因为应用大部分时间都在等待输入。 棋盘游戏和新闻阅读器是可能属于此类别的应用示例。
  2. 使用暂时性动画按需呈现。 此方案类似于第一个方案,不同之处在于某些类型的输入将启动不依赖于用户的后续输入的动画。 电源效率很好,因为游戏不会重复呈现相同的帧,并且输入延迟较低,而游戏未进行动画处理。 对每个移动进行动画处理的交互式儿童游戏和棋盘游戏是可能属于此类别的应用示例。
  3. 每秒呈现 60 帧。 在此方案中,游戏会不断更新屏幕。 电源效率不佳,因为它呈现显示的最大帧数。 输入延迟较高,因为 DirectX 在呈现内容时会阻止线程。 这样做可以防止线程向显示器发送更多的帧,而不是向用户显示。 第一人称射击手、实时策略游戏和基于物理的游戏是可能属于此类别的应用示例。
  4. 每秒渲染 60 帧,并实现尽可能低的输入延迟。 与方案 3 类似,应用会不断更新屏幕,因此电源效率会很差。 区别在于游戏在单独的线程上响应输入,因此,通过将图形呈现给显示器,不会阻止输入处理。 在线多人游戏、战斗游戏或节奏/计时游戏可能属于此类别,因为它们支持在极其紧张的事件窗口中移动输入。

实现

大多数 DirectX 游戏都由所谓的游戏循环驱动。 基本算法是执行这些步骤,直到用户退出游戏或应用:

  1. 处理输入
  2. 更新游戏状态
  3. 绘制游戏内容

当 DirectX 游戏的内容呈现并准备好呈现到屏幕时,游戏循环会等待 GPU 准备好接收新帧,然后唤醒以再次处理输入。

我们将通过循环访问简单的抖动谜题游戏来展示前面提到的每个场景的游戏循环的实现。 与每个实现讨论的决策点、优势和权衡可以作为指导,帮助你优化应用,实现低延迟输入和电源效率。

方案 1:按需呈现

抖动谜题游戏的第一次迭代仅在用户移动谜题时更新屏幕。 用户可以将一个谜题块拖到一个位置,也可以通过选择它并触摸正确的目标将其贴靠到位。 第二种情况下,谜题片将跳转到没有动画或效果的目标。

该代码在 IFrameworkView::Run 方法中具有单线程游戏循环,该方法使用 CoreProcessEventsOption::P rocessOneAndAllPending 使用此选项调度队列中所有当前可用的事件。 如果没有事件挂起,游戏循环将等待一个事件出现。

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

方案 2:使用暂时性动画按需呈现

在第二次迭代中,将修改游戏,以便当用户选择一个谜题片,然后触摸该作品的正确目标时,它会在屏幕上进行动画处理,直到到达其目标。

与之前一样,代码具有单线程游戏循环,该循环使用 ProcessOneAndAllPending 调度队列中的输入事件。 现在的差异在于,在动画期间,循环更改为使用 CoreProcessEventsOption::P rocessAllIfPresent ,以便它不会等待新的输入事件。 如果未挂起任何事件, ProcessEvents 将立即返回,并允许应用在动画中显示下一帧。 动画完成后,循环将 切换回 ProcessOneAndAllPending 以限制屏幕更新。

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

若要支持 ProcessOneAndAllPendingProcessAllIfPresent 之间的转换,应用必须跟踪状态,以了解其是否正在进行动画处理。 在 jigsaw 谜题应用中,可以通过添加可在 GameState 类上的游戏循环期间调用的新方法来执行此操作。 游戏循环的动画分支通过调用 GameState 的新 Update 方法来驱动动画状态的更新。

方案 3:每秒呈现 60 帧

第三次迭代中,应用显示一个计时器,显示用户处理谜题的时间。 因为它显示运行时间最长为毫秒,因此它必须呈现每秒 60 帧才能使显示保持最新状态。

与方案 1 和 2 一样,应用具有单线程游戏循环。 此方案的区别在于,因为它始终呈现,因此它不再需要跟踪游戏状态的更改,就像前两种方案一样。 因此,它默认使用 ProcessAllIfPresent 来处理事件。 如果没有挂起的事件, ProcessEvents 将立即返回,并继续呈现下一帧。

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

此方法是编写游戏的最简单方法,因为无需跟踪其他状态即可确定何时呈现。 它可实现最快的呈现,以及计时器间隔的合理输入响应能力。

然而,这种轻松的开发伴随着价格。 每秒 60 帧的渲染比按需渲染更强大。 当游戏更改每个帧显示的内容时,最好使用 ProcessAllIfPresent 。 它还增加了高达 16.7 毫秒的输入延迟,因为应用现在正在阻止显示同步间隔上的游戏循环,而不是在 ProcessEvents。 可能会删除某些输入事件,因为每个帧(60 Hz)只处理一次队列。

方案 4:每秒呈现 60 帧,并实现尽可能低的输入延迟

某些游戏可能能够忽略或补偿方案 3 中显示的输入延迟增加。 但是,如果低输入延迟对于游戏的体验和玩家反馈感至关重要,则呈现每秒 60 帧的游戏需要在单独的线程上处理输入。

抖动谜题游戏的第四次迭代基于方案 3,将游戏循环中的输入处理和图形呈现拆分为单独的线程。 为每个线程设置单独的线程可确保图形输出永远不会延迟输入;但是,代码因此变得更加复杂。 在方案 4 中,输入线程使用 CoreProcessEventsOption::P rocessUntilQuit 调用 ProcessEvents,后者等待新事件并调度所有可用事件。 它会继续此行为,直到窗口关闭或游戏调用 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);
}

Microsoft Visual Studio 2015 中的 DirectX 11 和 XAML 应用(通用 Windows)模板以类似的方式将游戏循环拆分为多个线程。 它使用 Windows::UI::Core::CoreIndependentInputSource 对象启动专用于处理输入的线程,并创建独立于 XAML UI 线程的呈现线程。 有关这些模板的更多详细信息,请阅读从模板创建通用 Windows 平台和 DirectX 游戏项目。

减少输入延迟的其他方法

使用可等待的交换链

DirectX 游戏通过更新用户在屏幕上看到的内容来响应用户输入。 在 60 Hz 显示器上,屏幕每隔 16.7 毫秒刷新一次(1 秒/60 帧)。 图 1 显示了呈现每秒 60 帧的应用相对于 16.7 毫秒刷新信号(VBlank)的输入事件的近似生命周期和响应:

图 1

图 1 directx 中的输入延迟

在 Windows 8.1 中,DXGI 引入了用于交换链的 DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT 标志,它使应用无需实现启发以保持 Present 队列为空,即可轻松地缩短该延迟。 使用此标志创建的交换链称为可等待的交换链。 图 2 显示了使用可等待的交换链时对输入事件的近似生命周期和响应:

图 2

directx 可等待的图 2 输入延迟

从这些关系图中看到,如果游戏能够呈现和呈现由显示器的刷新率定义的 16.7 毫秒预算中的每个帧,则游戏可能会降低两个完整帧的输入延迟。 jigsaw 谜题示例使用可等待的交换链,并通过调用控制当前队列限制: m_deviceResources->SetMaximumFrameLatency(1);