优化通用 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::ProcessOneAndAllPending。 使用此选项调度队列中所有当前可用的事件。 如果没有未处理的事件,游戏循环将等待直到有事件出现。

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之间的转换,应用必须跟踪状态才能知道它是否正在进行动画处理。 在拼图游戏应用中,可以通过在 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 中,输入线程调用 processEvents,使用 CoreProcessEventsOption::ProcessUntilQuit,这将等待新事件并调度所有可用事件。 它会继续此行为,直到窗口关闭或游戏调用 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

DirectX 中的 图 1 输入延迟

在 Windows 8.1 中,DXGI 为交换链引入了 DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT 标志,这使应用能够轻松减少此延迟,而无需实现启发法来保持 Present 队列清空。 使用此标志创建的交换链称为可等待的交换链。 图 2 显示了使用可等待的交换链时对输入事件的近似生命周期和响应:

图 2

directx 中 可等待的 图 2 输入延迟

从这些图表中可以看出,如果游戏能够在显示器刷新率定义的16.7毫秒预算内渲染和呈现每一帧,那么它们可能会将输入延迟最多降低两个完整帧。 拼图示例使用可等待的交换链,并通过调用来控制 Present 队列限制: m_deviceResources->SetMaximumFrameLatency(1);