在 DXGI 1.3 引入了新的功能,支持获得交换链发出开始渲染新帧的适当时机信号,通过等待此信号,可以降低输入的渲染延迟
在 上一篇博客 和大家介绍了如何在控制台里面用裸 DirectX 做一个简单绘制折线笔迹的 D2D 应用。此时的 D2D 应用的笔迹延迟还只是能够追得上 WPF 的笔迹性能,依然有很大的优化空间。本文将在此基础上,给出一个更低输入延迟的渲染方案
在一些紧张的射击类游戏里面,游戏开发者很注重于减少输入的渲染延迟。对桌面应用来说,也有很多领域有着相同的追求。比如笔迹类白板应用。这些应用都追求着尽快将用户的输入内容呈现在屏幕上
对于桌面类应用来说,有一个很讽刺的点在于,如果一个应用程序的一帧渲染时间足够短,那渲染线程很大的时间都是在等待交换链进行同步过程中。在等待的过程中,此时的 DWM 桌面窗口合成器还没能将窗口画面送出去渲染,在这段时间内的所有输入内容都将会被延迟到下一帧进行处理,甚至是下下帧进行处理
这就是著名的 Input latency (输入延迟)问题。解决此问题的方向有很多,在本文这里将和大家介绍的是在 Windows 8.1 中的 DXGI 1.3 版本引入的可等待交换链技术
本文属于 DirectX 系列博客,更多 DirectX 相关博客,请参阅 博客导航
在开始之前,我十分推荐大家先阅读 分享一个在 dotnet 里使用 D2D 配合 AOT 开发小而美的应用开发经验 这篇博客,通过阅读此博客,可以让大家理解一些常用概念
核心使用可等待交换链的代码很少,只需将从通过 IDXGIFactory2.CreateSwapChainForXxx 获得的 IDXGISwapChain1 当成 IDXGISwapChain2 对象,再设置 MaximumFrameLatency 为 1 的值,表示实现最低延迟,但其代价是降低 CPU-GPU 并行度。在本文的 Demo 里面,只会将最后的 WM_Pointer 点绘制出来,其 CPU 时间可以忽略,降低 CPU-GPU 并行度对此毫无影响
再获取 IDXGISwapChain2.FrameLatencyWaitableObject 可等待对象,通过 Win32 的 WaitForSingleObjectEx 方法等待此对象,即可获取是个适当的渲染前时机。在此时机将输入进行处理后传给交换链缓存即可获得很低的输入渲染延迟
核心代码示例如下:
var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();
IDXGISwapChain1 swapChain1 = dxgiFactory2.CreateSwapChainForXxx(...);
IDXGISwapChain2 swapChain2 = swapChain1.QueryInterface<IDXGISwapChain2>();
swapChain1.Dispose();
swapChain2.MaximumFrameLatency = 1;
var waitableObject = swapChain2.FrameLatencyWaitableObject;
while (渲染)
{
Kernal32.WaitForSingleObjectEx(new HANDLE(waitableObject), dwMilliseconds: 1000, bAlertable: true);
// 在此编写实际的渲染代码
swapChain2.Present(0, PresentFlags.None);
}
为什么用 WaitForSingleObjectEx(IDXGISwapChain2.FrameLatencyWaitableObject) 做等待会比用 IDXGISwapChain2.Present(1, ...) 的输入响应延迟更低?如 官方文档 的下面两张对比图片所示:
第一张图如下,显示的是传统的写法的情况,可能让第 5 个数据被延迟到第 5 帧才在屏幕显示出来

第二张图如下,这是在使用 Windows 8.1 引入的 DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT 可等待交换链技术的情况下,轻松地让输入的响应在第 3 帧渲染出来

如上图所示,可见采用此技术可能降低输入响应的渲染延迟
详细的设计如下:
- 让 UI 窗口消息循环线程和 渲染线程 分离
- 在 UI 窗口消息循环接收输入消息,如 WM_Pointer 消息。接收到之后,将信息进行缓存
- 当 渲染线程 获得渲染时机时,取最后一个 WM_Pointer 坐标进行绘制矩形
在低延迟的触摸屏设备上运行程序,可以尝试触摸移动,开启系统触摸反馈点,甚至是在触摸过程移动鼠标产生鼠标光标,用于对比此方案的输入渲染延迟
具体的代码分为三个部分:
- 窗口的创建和消息循环对 WM_Pointer 的处理
- 渲染线程的初始化,包括初始化 D2D 设备和挂交换链
- 渲染线程每一帧的处理逻辑
第一个部分没有什么特殊的,可参阅 dotnet DirectX 做一个简单绘制折线笔迹的 D2D 应用 博客了解对 WM_Pointer 消息的处理
如果大家对 WM_Pointer 消息感兴趣,还请参阅 WPF 从裸 Win 32 的 WM_Pointer 消息获取触摸点绘制笔迹
第一部分的代码在这里先简略给出,在本文末尾将给出完全的代码,和整个项目代码的下载方法
[SupportedOSPlatform("windows8.1")]
class DemoWindow
{
public DemoWindow()
{
var window = CreateWindow();
HWND = window;
// 让鼠标也引发 WM_Pointer 事件
EnableMouseInPointer(true);
// 显示窗口
ShowWindow(window, SHOW_WINDOW_CMD.SW_SHOW);
}
public HWND HWND { get; }
public unsafe void Run()
{
while (true)
{
var msg = new MSG();
var getMessageResult = GetMessage(&msg, HWND, 0,
0);
if (!getMessageResult)
{
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
/// <summary>
/// 仅用于防止被回收
/// </summary>
/// <returns></returns>
private WNDPROC? _wndProcDelegate;
private unsafe HWND CreateWindow()
{
var windowHwnd = CreateWindowEx(...);
return windowHwnd;
}
private unsafe LRESULT WndProc(HWND hwnd, uint message, WPARAM wParam, LPARAM lParam)
{
if (message == WM_POINTERUPDATE /*Pointer Update*/)
{
var pointerId = (uint) (ToInt32(wParam) & 0xFFFF);
...;
var x = ...; // 对 pointerInfo.ptHimetricLocationRaw.X 进行处理
var y = ...; // 对 pointerInfo.ptHimetricLocationRaw.Y 进行处理
// 通知渲染线程处理
}
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
以上是一个标准的窗口的写法。以上代码将被放在 UI 线程执行。再开启另一个线程作为渲染线程
渲染线程执行的是第二部分的代码,其初始化逻辑前置部分没有什么特殊的,按部就班创建交换链。本文这里将使用 IDXGIFactory2.CreateSwapChainForHwnd 创建交换链。除此之外,还可以使用 IDXGIFactory2.CreateSwapChainForComposition 等方法创建交换链。详细请参阅 Vortice 使用 DirectComposition 显示透明窗口
前置代码的核心部分如下,可在本文末尾找到全部的代码
[SupportedOSPlatform("windows8.1")]
unsafe class RenderManager(HWND hwnd) : IDisposable
{
public HWND HWND => hwnd;
private void Init()
{
var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();
D3D11.D3D11CreateDevice
(
...,
out ID3D11Device d3D11Device,
...
);
// 大部分情况下,用的是 ID3D11Device1 和 ID3D11DeviceContext1 类型
// 从 ID3D11Device 转换为 ID3D11Device1 类型
ID3D11Device1 d3D11Device1 = d3D11Device.QueryInterface<ID3D11Device1>();
IDXGISwapChain1 swapChain1 = dxgiFactory2.CreateSwapChainForHwnd(d3D11Device1, HWND, ...);
... // 处理交换链的逻辑
}
}
如对此前置代码的实现原理感兴趣,还请参阅 DirectX 使用 Vortice 从零开始控制台创建 Direct2D1 窗口修改颜色
通过前置代码即可拿到 IDXGISwapChain1 交换链。按照上文提供的核心实现方法,将 IDXGISwapChain1 转为 IDXGISwapChain2 对象。再设置 MaximumFrameLatency 属性和获取 FrameLatencyWaitableObject 对象
IDXGISwapChain2 swapChain2 = swapChain1.QueryInterface<IDXGISwapChain2>();
swapChain1.Dispose();
swapChain2.MaximumFrameLatency = 1;
var waitableObject = swapChain2.FrameLatencyWaitableObject;
_ = waitableObject;
// 可以通过 WaitForSingleObjectEx 进行等待
将以上的初始化逻辑放在渲染线程里面执行,其代码如下
[SupportedOSPlatform("windows8.1")]
unsafe class RenderManager(HWND hwnd) : IDisposable
{
public void StartRenderThread()
{
var thread = new Thread(() => { RenderCore(); })
{
IsBackground = true,
Name = "Render"
};
thread.Priority = ThreadPriority.Highest;
thread.Start();
}
private void RenderCore()
{
Init();
...
}
private void Init()
{
var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();
D3D11.D3D11CreateDevice
(
...,
out ID3D11Device d3D11Device,
...
);
// 大部分情况下,用的是 ID3D11Device1 和 ID3D11DeviceContext1 类型
// 从 ID3D11Device 转换为 ID3D11Device1 类型
ID3D11Device1 d3D11Device1 = d3D11Device.QueryInterface<ID3D11Device1>();
IDXGISwapChain1 swapChain1 = dxgiFactory2.CreateSwapChainForHwnd(d3D11Device1, HWND, ...);
IDXGISwapChain2 swapChain2 = swapChain1.QueryInterface<IDXGISwapChain2>();
swapChain1.Dispose();
swapChain2.MaximumFrameLatency = 1;
var waitableObject = swapChain2.FrameLatencyWaitableObject;
_ = waitableObject;
// 可以通过 WaitForSingleObjectEx 进行等待
}
...
}
在 RenderCore 还需要对接 D2D 用于渲染,其核心代码如下
using D2D.ID2D1Factory1 d2DFactory = D2D.D2D1.D2D1CreateFactory<D2D.ID2D1Factory1>();
var d3D11Texture2D = swapChain2.GetBuffer<ID3D11Texture2D>(0);
using var dxgiSurface = d3D11Texture2D.QueryInterface<IDXGISurface>();
D2D.ID2D1RenderTarget d2D1RenderTarget =
d2DFactory.CreateDxgiSurfaceRenderTarget(dxgiSurface, ...);
拿到 ID2D1RenderTarget 对象即可在渲染逻辑里面对接渲染
第三部分为每一帧执行的逻辑。在 RenderManager 里提供 Move 方法,用于接收当前的 Pointer 的坐标点,其代码如下
public void Move(double x, double y)
{
_position = new Position(x, y);
}
private Position _position = new Position(0, 0);
/// <summary>
/// 表示当前的位置
/// </summary>
/// <param name="X"></param>
/// <param name="Y"></param>
/// <remarks>
/// 为什么需要选用 record 引用 class 类型,而不是 struct 结构体值类型?这是为了在渲染线程和 UI 线程之间共享这个位置数据。由于 record class 是引用类型,所以在两个线程之间共享时,不需要担心值类型的复制问题,完全原子化,不存在多线程安全问题
/// </remarks>
record Position(double X, double Y);
为了更好地测试输入延迟,在本文中只考虑 Pointer 的最后一次的坐标点,中间点将被覆盖丢弃。由于消息是从 UI 线程接收的,而每次渲染都在渲染线程执行,为了解决多线程安全问题,就将 Position 类型设计为 class 引用类型。这是因为对引用类型的赋值底层是一次指针赋值过程,本身就是 CPU 确保的原子化动作,不会存在多线程安全问题
同步地在消息循环里将处理到的坐标点调用 Move 方法传递到渲染线程
class DemoWindow
{
...
public unsafe void Run()
{
_renderManager = new RenderManager(HWND);
_renderManager.StartRenderThread();
while (true)
{
var msg = new MSG();
var getMessageResult = GetMessage(&msg, HWND, 0,
0);
if (!getMessageResult)
{
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
private unsafe LRESULT WndProc(HWND hwnd, uint message, WPARAM wParam, LPARAM lParam)
{
if (message == WM_POINTERUPDATE /*Pointer Update*/)
{
var pointerId = (uint) (ToInt32(wParam) & 0xFFFF);
...;
var x = ...; // 对 pointerInfo.ptHimetricLocationRaw.X 进行处理
var y = ...; // 对 pointerInfo.ptHimetricLocationRaw.Y 进行处理
_renderManager?.Move(x, y);
}
return DefWindowProc(hwnd, message, wParam, lParam);
}
private RenderManager? _renderManager;
}
在每一帧的开始,先使用 Kernal32.WaitForSingleObjectEx 等待 IDXGISwapChain2.FrameLatencyWaitableObject 对象,随后再处理输入数据
var waitableObject = swapChain2.FrameLatencyWaitableObject;
using var brush = d2D1RenderTarget.CreateSolidColorBrush(Colors.Yellow);
while (渲染)
{
WaitForSingleObjectEx(new HANDLE(waitableObject), dwMilliseconds: 1000, bAlertable: true);
// 渲染代码写在这里:
D2D.ID2D1RenderTarget renderTarget = d2D1RenderTarget;
renderTarget.BeginDraw();
renderTarget.Clear(Colors.White);
var position = _position;
// 在输入的坐标上,绘制矩形
var rectangleSize = 50;
renderTarget.FillRectangle(new Rect((float) position.X, (float) position.Y, rectangleSize, rectangleSize), brush);
renderTarget.EndDraw();
swapChain2.Present(0, PresentFlags.None);
}
尝试运行代码,最好是脱离 Visual Studio 调试的 Release 版,在低延迟触摸屏或高精度鼠标的设备上运行程序,可见此应用绘制的矩形是非常跟手的。在触摸屏上尝试打开触摸反馈点(设置->辅助功能->鼠标指针与触控->触控指示器->使圆圈更深更大)时,可见矩形左上角将保持在触摸反馈点中心。如此即可证明渲染的输入响应延迟非常低
本文的非 PInvoke 的关键代码全放在 Program.cs 文件里面,代码如下
using KearjerijarqaloChurharcarwaya.Diagnostics;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
using Vortice.DCommon;
using Vortice.Direct3D;
using Vortice.Direct3D11;
using Vortice.DirectComposition;
using Vortice.DXGI;
using Vortice.Mathematics;
using Vortice.Win32;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.Input.Pointer;
using Windows.Win32.UI.WindowsAndMessaging;
using static Windows.Win32.PInvoke;
using AlphaMode = Vortice.DXGI.AlphaMode;
using Color = Vortice.Mathematics.Color;
using D2D = Vortice.Direct2D1;
namespace KearjerijarqaloChurharcarwaya;
class Program
{
[STAThread]
static void Main(string[] args)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(8, 1))
{
return;
}
var demoWindow = new DemoWindow();
demoWindow.Run();
Console.ReadLine();
}
}
[SupportedOSPlatform("windows8.1")]
class DemoWindow
{
public DemoWindow()
{
var window = CreateWindow();
HWND = window;
// 让鼠标也引发 WM_Pointer 事件
EnableMouseInPointer(true);
// 最大化显示窗口
ShowWindow(window, SHOW_WINDOW_CMD.SW_SHOW);
// 独立渲染线程
var renderManager = new RenderManager(window);
_renderManager = renderManager;
renderManager.StartRenderThread();
}
private readonly RenderManager _renderManager;
public HWND HWND { get; }
public unsafe void Run()
{
while (true)
{
var msg = new MSG();
var getMessageResult = GetMessage(&msg, HWND, 0,
0);
if (!getMessageResult)
{
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
/// <summary>
/// 仅用于防止被回收
/// </summary>
/// <returns></returns>
private WNDPROC? _wndProcDelegate;
private unsafe HWND CreateWindow()
{
WINDOW_EX_STYLE exStyle = WINDOW_EX_STYLE.WS_EX_APPWINDOW;
var style = WNDCLASS_STYLES.CS_OWNDC | WNDCLASS_STYLES.CS_HREDRAW | WNDCLASS_STYLES.CS_VREDRAW;
var defaultCursor = LoadCursor(
new HINSTANCE(IntPtr.Zero), new PCWSTR(IDC_ARROW.Value));
var className = $"lindexi-{Guid.NewGuid().ToString()}";
var title = "The Title";
fixed (char* pClassName = className)
fixed (char* pTitle = title)
{
_wndProcDelegate = new WNDPROC(WndProc);
var wndClassEx = new WNDCLASSEXW
{
cbSize = (uint) Marshal.SizeOf<WNDCLASSEXW>(),
style = style,
lpfnWndProc = _wndProcDelegate,
hInstance = new HINSTANCE(GetModuleHandle(null).DangerousGetHandle()),
hCursor = defaultCursor,
hbrBackground = new HBRUSH(IntPtr.Zero),
lpszClassName = new PCWSTR(pClassName)
};
ushort atom = RegisterClassEx(in wndClassEx);
WINDOW_STYLE dwStyle = WINDOW_STYLE.WS_OVERLAPPEDWINDOW | WINDOW_STYLE.WS_VISIBLE | WINDOW_STYLE.WS_CAPTION | WINDOW_STYLE.WS_SYSMENU | WINDOW_STYLE.WS_MINIMIZEBOX | WINDOW_STYLE.WS_CLIPCHILDREN | WINDOW_STYLE.WS_BORDER | WINDOW_STYLE.WS_DLGFRAME | WINDOW_STYLE.WS_THICKFRAME | WINDOW_STYLE.WS_TABSTOP | WINDOW_STYLE.WS_SIZEBOX;
var windowHwnd = CreateWindowEx(
exStyle,
new PCWSTR((char*) atom),
new PCWSTR(pTitle),
dwStyle,
0, 0, 1900, 1000,
HWND.Null, HMENU.Null, HINSTANCE.Null, null);
return windowHwnd;
}
}
private unsafe LRESULT WndProc(HWND hwnd, uint message, WPARAM wParam, LPARAM lParam)
{
if (message == WM_POINTERUPDATE /*Pointer Update*/)
{
var pointerId = (uint) (ToInt32(wParam) & 0xFFFF);
global::Windows.Win32.Foundation.RECT pointerDeviceRect = default;
global::Windows.Win32.Foundation.RECT displayRect = default;
GetPointerTouchInfo(pointerId, out POINTER_TOUCH_INFO pointerTouchInfo);
var pointerInfo = pointerTouchInfo.pointerInfo;
GetPointerDeviceRects(pointerInfo.sourceDevice, &pointerDeviceRect, &displayRect);
var x =
pointerInfo.ptHimetricLocationRaw.X / (double) pointerDeviceRect.Width * displayRect.Width +
displayRect.left;
var y = pointerInfo.ptHimetricLocationRaw.Y / (double) pointerDeviceRect.Height * displayRect.Height +
displayRect.top;
var screenTranslate = new Point(0, 0);
ClientToScreen(HWND, ref screenTranslate);
x -= screenTranslate.X;
y -= screenTranslate.Y;
_renderManager.Move(x, y);
}
return DefWindowProc(hwnd, message, wParam, lParam);
}
private static int ToInt32(WPARAM wParam) => ToInt32((IntPtr) wParam.Value);
private static int ToInt32(IntPtr ptr) => IntPtr.Size == 4 ? ptr.ToInt32() : (int) (ptr.ToInt64() & 0xffffffff);
}
[SupportedOSPlatform("windows8.1")]
unsafe class RenderManager(HWND hwnd) : IDisposable
{
public HWND HWND => hwnd;
private readonly Format _colorFormat = Format.B8G8R8A8_UNorm;
private Format D2DColorFormat => _colorFormat;
/// <summary>
/// 缓存的数量,包括前缓存。大部分应用来说,至少需要两个缓存,这个玩过游戏的伙伴都知道
/// </summary>
private const int FrameCount = 2;
public void StartRenderThread()
{
var thread = new Thread(() => { RenderCore(); })
{
IsBackground = true,
Name = "Render"
};
thread.Priority = ThreadPriority.Highest;
thread.Start();
}
private void RenderCore()
{
Init();
using D2D.ID2D1Factory1 d2DFactory = D2D.D2D1.D2D1CreateFactory<D2D.ID2D1Factory1>();
IDXGISwapChain2 swapChain2 = _renderContext.SwapChain;
var d3D11Texture2D = swapChain2.GetBuffer<ID3D11Texture2D>(0);
using var dxgiSurface = d3D11Texture2D.QueryInterface<IDXGISurface>();
var renderTargetProperties = new D2D.RenderTargetProperties()
{
PixelFormat = new PixelFormat(D2DColorFormat, Vortice.DCommon.AlphaMode.Premultiplied),
Type = D2D.RenderTargetType.Hardware,
};
D2D.ID2D1RenderTarget d2D1RenderTarget =
d2DFactory.CreateDxgiSurfaceRenderTarget(dxgiSurface, renderTargetProperties);
var waitableObject = swapChain2.FrameLatencyWaitableObject;
using var brush = d2D1RenderTarget.CreateSolidColorBrush(Colors.Yellow);
while (!_isDisposed)
{
using (StepPerformanceCounter.RenderThreadCounter.StepStart("FrameLatencyWaitableObject"))
{
WaitForSingleObjectEx(new HANDLE(waitableObject), 1000, true);
}
// 渲染代码写在这里
using (StepPerformanceCounter.RenderThreadCounter.StepStart("Render"))
{
D2D.ID2D1RenderTarget renderTarget = d2D1RenderTarget;
renderTarget.BeginDraw();
renderTarget.Clear(Colors.White);
var position = _position;
var rectangleSize = 50;
renderTarget.FillRectangle(new Rect((float) position.X, (float) position.Y, rectangleSize, rectangleSize), brush);
renderTarget.EndDraw();
}
using (StepPerformanceCounter.RenderThreadCounter.StepStart("SwapChain"))
{
swapChain2.Present(0, PresentFlags.None);
}
}
}
private void Init()
{
RECT windowRect;
GetClientRect(HWND, &windowRect);
var clientSize = new SizeI(windowRect.right - windowRect.left, windowRect.bottom - windowRect.top);
var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();
IDXGIAdapter1? hardwareAdapter = GetHardwareAdapter(dxgiFactory2)
// 这里 ToList 只是想列出所有的 IDXGIAdapter1 在实际代码里,大部分都是获取第一个
.ToList().FirstOrDefault();
if (hardwareAdapter == null)
{
throw new InvalidOperationException("Cannot detect D3D11 adapter");
}
FeatureLevel[] featureLevels = new[]
{
FeatureLevel.Level_11_1,
FeatureLevel.Level_11_0,
FeatureLevel.Level_10_1,
FeatureLevel.Level_10_0,
FeatureLevel.Level_9_3,
FeatureLevel.Level_9_2,
FeatureLevel.Level_9_1,
};
IDXGIAdapter1 adapter = hardwareAdapter;
DeviceCreationFlags creationFlags = DeviceCreationFlags.BgraSupport;
var result = D3D11.D3D11CreateDevice
(
adapter,
DriverType.Unknown,
creationFlags,
featureLevels,
out ID3D11Device d3D11Device, out FeatureLevel featureLevel,
out ID3D11DeviceContext d3D11DeviceContext
);
_ = featureLevel;
if (result.Failure)
{
// 如果失败了,那就不指定显卡,走 WARP 的方式
// http://go.microsoft.com/fwlink/?LinkId=286690
result = D3D11.D3D11CreateDevice(
IntPtr.Zero,
DriverType.Warp,
creationFlags,
featureLevels,
out d3D11Device, out featureLevel, out d3D11DeviceContext);
// 如果失败,就不能继续
result.CheckError();
}
// 大部分情况下,用的是 ID3D11Device1 和 ID3D11DeviceContext1 类型
// 从 ID3D11Device 转换为 ID3D11Device1 类型
ID3D11Device1 d3D11Device1 = d3D11Device.QueryInterface<ID3D11Device1>();
var d3D11DeviceContext1 = d3D11DeviceContext.QueryInterface<ID3D11DeviceContext1>();
// 获取到了新的两个接口,就可以减少 `d3D11Device` 和 `d3D11DeviceContext` 的引用计数。调用 Dispose 不会释放掉刚才申请的 D3D 资源,只是减少引用计数
d3D11Device.Dispose();
d3D11DeviceContext.Dispose();
SwapChainDescription1 swapChainDescription = new()
{
Width = (uint) clientSize.Width,
Height = (uint) clientSize.Height,
Format = _colorFormat,
BufferCount = FrameCount,
BufferUsage = Usage.RenderTargetOutput,
SampleDescription = SampleDescription.Default,
Scaling = Scaling.Stretch,
SwapEffect = SwapEffect.FlipSequential, // 使用 FlipSequential 配合 Composition
AlphaMode = AlphaMode.Ignore,
Flags = SwapChainFlags.FrameLatencyWaitableObject, // 核心设置
};
var fullscreenDescription = new SwapChainFullscreenDescription()
{
Windowed = true,
};
IDXGISwapChain1 swapChain1 = dxgiFactory2.CreateSwapChainForHwnd(d3D11Device1, HWND, swapChainDescription, fullscreenDescription);
IDXGISwapChain2 swapChain2 = swapChain1.QueryInterface<IDXGISwapChain2>();
swapChain1.Dispose();
swapChain2.MaximumFrameLatency = 1;
var waitableObject = swapChain2.FrameLatencyWaitableObject;
_ = waitableObject;
// 可以通过 WaitForSingleObjectEx 进行等待
// 不要被按下 alt+enter 进入全屏
dxgiFactory2.MakeWindowAssociation(HWND,
WindowAssociationFlags.IgnoreAltEnter | WindowAssociationFlags.IgnorePrintScreen);
_renderContext = _renderContext with
{
DXGIFactory2 = dxgiFactory2,
HardwareAdapter = hardwareAdapter,
D3D11Device1 = d3D11Device1,
D3D11DeviceContext1 = d3D11DeviceContext1,
SwapChain = swapChain2,
WindowWidth = swapChainDescription.Width,
WindowHeight = swapChainDescription.Height
};
}
private static IEnumerable<IDXGIAdapter1> GetHardwareAdapter(IDXGIFactory2 factory)
{
using IDXGIFactory6? factory6 = factory.QueryInterfaceOrNull<IDXGIFactory6>();
if (factory6 != null)
{
// 这个系统的 DX 支持 IDXGIFactory6 类型
// 先告诉系统,要高性能的显卡
for (uint adapterIndex = 0;
factory6.EnumAdapterByGpuPreference(adapterIndex, GpuPreference.HighPerformance,
out IDXGIAdapter1? adapter).Success;
adapterIndex++)
{
if (adapter == null)
{
continue;
}
AdapterDescription1 desc = adapter.Description1;
if ((desc.Flags & AdapterFlags.Software) != AdapterFlags.None)
{
// Don't select the Basic Render Driver adapter.
adapter.Dispose();
continue;
}
Console.WriteLine($"枚举到 {adapter.Description1.Description} 显卡");
yield return adapter;
}
}
else
{
// 不支持就不支持咯,用旧版本的方式获取显示适配器接口
}
// 如果枚举不到,那系统返回啥都可以
for (uint adapterIndex = 0;
factory.EnumAdapters1(adapterIndex, out IDXGIAdapter1? adapter).Success;
adapterIndex++)
{
AdapterDescription1 desc = adapter.Description1;
if ((desc.Flags & AdapterFlags.Software) != AdapterFlags.None)
{
// Don't select the Basic Render Driver adapter.
adapter.Dispose();
continue;
}
Console.WriteLine($"枚举到 {adapter.Description1.Description} 显卡");
yield return adapter;
}
}
private RenderContext _renderContext;
public void Dispose()
{
_renderContext.Dispose();
_isDisposed = true;
}
private bool _isDisposed;
public void Move(double x, double y)
{
_position = new Position(x, y);
}
private Position _position = new Position(0, 0);
/// <summary>
/// 表示当前的位置
/// </summary>
/// <param name="X"></param>
/// <param name="Y"></param>
/// <remarks>
/// 为什么需要选用 record 引用 class 类型,而不是 struct 结构体值类型?这是为了在渲染线程和 UI 线程之间共享这个位置数据。由于 record class 是引用类型,所以在两个线程之间共享时,不需要担心值类型的复制问题,完全原子化,不存在多线程安全问题
/// </remarks>
record Position(double X, double Y);
}
readonly record struct RenderContext(
IDXGIFactory2 DXGIFactory2,
IDXGIAdapter1 HardwareAdapter,
ID3D11Device1 D3D11Device1,
ID3D11DeviceContext1 D3D11DeviceContext1,
IDXGISwapChain2 SwapChain) : IDisposable
{
public uint WindowWidth { get; init; }
public uint WindowHeight { get; init; }
public void Dispose()
{
DXGIFactory2.Dispose();
HardwareAdapter.Dispose();
D3D11Device1.Dispose();
D3D11DeviceContext1.Dispose();
SwapChain.Dispose();
}
}
以上代码使用的 StepPerformanceCounter 只是一个调试辅助代码,用于记录耗时,具体实现在此略过
项目文件 csproj 代码如下
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Vortice.Direct2D1" Version="3.8.2" />
<PackageReference Include="Vortice.Direct3D11" Version="3.8.2" />
<PackageReference Include="Vortice.DirectComposition" Version="3.8.2" />
<PackageReference Include="Vortice.DXGI" Version="3.8.2" />
<PackageReference Include="Vortice.Win32" Version="2.3.0" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.257">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="MicroCom.Runtime" Version="0.11.0" />
</ItemGroup>
</Project>
可见是支持 AOT 发布的,可在此基础上扩展出一个高性能低延迟笔迹应用的画板部分。但需要说明的是,即使上了此技术,也只是追平 WPF 的笔迹应用性能而已。如对触摸相关感兴趣,还请参阅 WPF 触摸相关
依赖的 CsWin32 配置的 NativeMethods.txt 文件的代码如下
EnumDisplayMonitors
GetMonitorInfo
MONITORINFOEXW
EnumDisplaySettings
GetDisplayConfigBufferSizes
QueryDisplayConfig
DisplayConfigGetDeviceInfo
RegisterClassEx
GetModuleHandle
LoadCursor
IDC_ARROW
CreateWindowEx
CW_USEDEFAULT
ShowWindow
SHOW_WINDOW_CMD
GetMessage
TranslateMessage
DispatchMessage
DefWindowProc
GetClientRect
GetWindowLong
SetWindowLong
NCCALCSIZE_PARAMS
WaitForSingleObjectEx
ClientToScreen
WM_POINTERUPDATE
GetPointerDeviceRects
GetPointerTouchInfo
EnableMouseInPointer
如不知道整个项目是如何组织的,还请按照如下方法拉取所有的代码获取全部代码
本文代码放在 github 和 gitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快
先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 2581b6d3b962e1f9912ebf359de3afbda4ab7e78
以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 2581b6d3b962e1f9912ebf359de3afbda4ab7e78
获取代码之后,进入 DirectX/D2D/KearjerijarqaloChurharcarwaya 文件夹,即可获取到源代码。欢迎大家拉下来代码跑跑看性能,这个简单的应用能够追平 WPF 的笔迹应用的性能,可以看到矩形左上角的点能够完全追平系统触摸反馈点的中心点。如果在触摸移动过程中,移动鼠标,让鼠标光标显示,则可以看到矩形左上角稍微落后鼠标光标一点点。如此即可证明此方案能够获得比较低的输入延迟
更多渲染和触摸博客,请参阅 博客导航
参考文档:
Reduce latency with DXGI 1.3 swap chains - UWP applications - Microsoft Learn
Multiple Buffering 会不会增加游戏的响应延迟? - 知乎
DX 11 如何使用 triple buffering? - 知乎
优化 UWP DirectX 游戏的输入延迟 - UWP applications - Microsoft Learn
使用 DXGI 1.3 交换链减少延迟 - UWP applications - Microsoft Learn
这篇博客发出来之后,收到几位伙伴问我说,为什么可等待交换链能够获取更低的输入延迟。他们看了官方文档和我贴出来的原因,感觉还是很困惑。在群里聊天的时候,我猛然发现,我应该将最关键的 IDXGISwapChain1.Present 的预留帧的问题给出来,这一下就能让大家理解了
调用 IDXGISwapChain1.Present 的时候,如果是快速频率调用,那开始的几帧将会被压在队列里面。如此的设计目的有二:一是跑分的时候好看,二是解决 CPU 抖动的时候的帧率抖动问题
证据如下:
尝试在一个无限循环里面,调用 IDXGISwapChain1.Present 方法,记录方法的耗时
代码如下
IDXGISwapChain1 swapChain = ...;
D2D.ID2D1RenderTarget renderTarget = ...;
var maxCount = 100;
var stepTimeList = new List<TimeSpan>();
var stopwatch = new Stopwatch();
while (true)
{
renderTarget.BeginDraw();
renderTarget.Clear(new Color4((uint) Random.Shared.Next()));
renderTarget.EndDraw();
stopwatch.Restart();
swapChain.Present(1, 0);
stopwatch.Stop();
if (stepTimeList.Count < maxCount)
{
stepTimeList.Add(stopwatch.Elapsed);
}
...
}
尝试将 stepTimeList 列表打印出来,可见大概如下内容
- [000] 0.173 ms
- [001] 0.171 ms
- [002] 0.591 ms
- [003] 7.008 ms
- [004] 16.074 ms
- [005] 17.490 ms
- [006] 10.339 ms
- [007] 18.348 ms
- [008] 12.287 ms
- [009] 15.211 ms
- [010] 16.125 ms
- [011] 16.110 ms
- [012] 16.100 ms
- [013] 2.887 ms
- [014] 2.171 ms
- [015] 2.223 ms
- [016] 2.365 ms
- [017] 15.942 ms
- [018] 15.915 ms
- [019] 15.896 ms
- [020] 15.930 ms
- [021] 15.950 ms
- [022] 15.903 ms
- [023] 15.602 ms
- [024] 16.525 ms
- [025] 15.597 ms
- [026] 15.878 ms
- [027] 15.908 ms
- [028] 15.912 ms
- [029] 16.046 ms
- [030] 15.938 ms
可见前面几帧都是瞬间完成的,这是因为前面几帧都将加入到队列里面,而不是有等待。后面发现队列满了,才会作为 16 毫秒(60帧)的时间
这就是一开始的贴出来的两张图片的意义:


可见在没有开启可等待交换链的时候,有很多的帧是压在队列里面,从而让响应交互的画面会延迟很多帧才能在屏幕上显示出来
开启可等待交换链的情况下,可以等待队列空闲才压入下一帧。此时就可以很好地让当前的响应交互输入的画面快速地排上渲染
此时如果使用 DwmFlush 对齐刷新率呢?每个循环里面都使用 DwmFlush 对齐刷新率,避免一开始就压入很多帧进去呢?这样是确实有帮助的
while (true)
{
renderTarget.BeginDraw();
renderTarget.Clear(new Color4((uint) Random.Shared.Next()));
renderTarget.EndDraw();
swapChain.Present(1, 0);
DwmFlush();
}
此时如果测量的话,会看到 IDXGISwapChain1.Present 的耗时非常短,这是因为此时队列接近是空,无需排队无需等待
必须说明的是,正常情况下,使用 IDXGISwapChain1.Present 对齐刷新率才是通用的做法。调用 DwmFlush 做渲染对齐是不得已才会考虑的
如果对 DwmFlush 感兴趣,请参阅 dotnet C# Windows 桌面应用程序简单使用 DwmFlush 对齐刷新率
以上的全部测试代码如下
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using Vortice.DCommon;
using Vortice.Direct3D;
using Vortice.Direct3D11;
using Vortice.DXGI;
using Vortice.Mathematics;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.WindowsAndMessaging;
using static Windows.Win32.PInvoke;
using AlphaMode = Vortice.DXGI.AlphaMode;
using D2D = Vortice.Direct2D1;
namespace JecekelbereLaiwharhowhelli;
class Program
{
[STAThread]
static unsafe void Main(string[] args)
{
HWND window;
#region 创建窗口
WINDOW_EX_STYLE exStyle = WINDOW_EX_STYLE.WS_EX_APPWINDOW;
var style = WNDCLASS_STYLES.CS_OWNDC | WNDCLASS_STYLES.CS_HREDRAW | WNDCLASS_STYLES.CS_VREDRAW;
var defaultCursor = LoadCursor(
new HINSTANCE(IntPtr.Zero), new PCWSTR(IDC_ARROW.Value));
var wndProcDelegate = new WNDPROC(WndProc);
var className = $"lindexi-{Guid.NewGuid().ToString()}";
var title = "The Title";
fixed (char* pClassName = className)
fixed (char* pTitle = title)
{
var wndClassEx = new WNDCLASSEXW
{
cbSize = (uint) Marshal.SizeOf<WNDCLASSEXW>(),
style = style,
lpfnWndProc = wndProcDelegate,
hInstance = new HINSTANCE(GetModuleHandle(null).DangerousGetHandle()),
hCursor = defaultCursor,
hbrBackground = new HBRUSH(IntPtr.Zero),
lpszClassName = new PCWSTR(pClassName)
};
ushort atom = RegisterClassEx(in wndClassEx);
WINDOW_STYLE dwStyle = WINDOW_STYLE.WS_OVERLAPPEDWINDOW | WINDOW_STYLE.WS_VISIBLE | WINDOW_STYLE.WS_CAPTION | WINDOW_STYLE.WS_SYSMENU | WINDOW_STYLE.WS_MINIMIZEBOX | WINDOW_STYLE.WS_CLIPCHILDREN | WINDOW_STYLE.WS_BORDER | WINDOW_STYLE.WS_DLGFRAME | WINDOW_STYLE.WS_THICKFRAME | WINDOW_STYLE.WS_TABSTOP | WINDOW_STYLE.WS_SIZEBOX;
HWND windowHwnd = CreateWindowEx(
exStyle,
new PCWSTR((char*) atom),
new PCWSTR(pTitle),
dwStyle,
0, 0, 1900, 1000,
HWND.Null, HMENU.Null, HINSTANCE.Null, null);
window = windowHwnd;
}
// 防止委托对象被回收,导致注册进去的方法指针失效
GC.KeepAlive(wndProcDelegate); // 保稳来说,这句话应该放在方法末尾
#endregion
// 显示窗口
ShowWindow(window, SHOW_WINDOW_CMD.SW_NORMAL);
RECT windowRect;
GetClientRect(window, &windowRect);
GetClientRect(window, &windowRect);
var clientSize = new SizeI(windowRect.right - windowRect.left, windowRect.bottom - windowRect.top);
#region 初始化 DX 相关
DeviceCreationFlags creationFlags = DeviceCreationFlags.BgraSupport;
var result = D3D11.D3D11CreateDevice
(
null,
DriverType.Hardware,
creationFlags,
null,
out ID3D11Device? d3D11Device
);
result.CheckError();
Debug.Assert(d3D11Device != null);
// 缓存的数量,包括前缓存。大部分应用来说,至少需要两个缓存,这个玩过游戏的伙伴都知道
const int FrameCount = 2;
Format colorFormat = Format.B8G8R8A8_UNorm;
SwapChainDescription1 swapChainDescription = new()
{
Width = (uint) clientSize.Width,
Height = (uint) clientSize.Height,
Format = colorFormat,
BufferCount = FrameCount,
BufferUsage = Usage.RenderTargetOutput,
SampleDescription = SampleDescription.Default,
Scaling = Scaling.Stretch,
SwapEffect = SwapEffect.FlipSequential,
AlphaMode = AlphaMode.Ignore,
Flags = SwapChainFlags.None,
};
var fullscreenDescription = new SwapChainFullscreenDescription()
{
Windowed = true,
};
using var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();
using IDXGISwapChain1 swapChain =
dxgiFactory2.CreateSwapChainForHwnd(d3D11Device, window, swapChainDescription, fullscreenDescription);
// 不要被按下 alt+enter 进入全屏
dxgiFactory2.MakeWindowAssociation(window,
WindowAssociationFlags.IgnoreAltEnter | WindowAssociationFlags.IgnorePrintScreen);
#endregion
#region 对接 D2D 渲染
using var d3D11Texture2D = swapChain.GetBuffer<ID3D11Texture2D>(0);
using var dxgiSurface = d3D11Texture2D.QueryInterface<IDXGISurface>();
var renderTargetProperties = new D2D.RenderTargetProperties()
{
PixelFormat = new PixelFormat(colorFormat, Vortice.DCommon.AlphaMode.Premultiplied),
Type = D2D.RenderTargetType.Hardware,
};
using D2D.ID2D1Factory1 d2DFactory = D2D.D2D1.D2D1CreateFactory<D2D.ID2D1Factory1>();
D2D.ID2D1RenderTarget d2D1RenderTarget =
d2DFactory.CreateDxgiSurfaceRenderTarget(dxgiSurface, renderTargetProperties);
D2D.ID2D1RenderTarget renderTarget = d2D1RenderTarget;
#endregion
var maxCount = 100;
var stepTimeList = new List<TimeSpan>();
var stopwatch = new Stopwatch();
while (true)
{
renderTarget.BeginDraw();
renderTarget.Clear(new Color4((uint) Random.Shared.Next()));
renderTarget.EndDraw();
stopwatch.Restart();
swapChain.Present(1, 0);
stopwatch.Stop();
if (stepTimeList.Count < maxCount)
{
stepTimeList.Add(stopwatch.Elapsed);
}
else
{
var stringBuilder = new StringBuilder();
for (var i = 0; i < stepTimeList.Count; i++)
{
var timeSpan = stepTimeList[i];
stringBuilder.AppendLine($"[{i:D3}] {timeSpan.TotalMilliseconds:0.000} ms");
}
var costText = stringBuilder.ToString();
Console.WriteLine(costText);
/*
[000] 0.173 ms
[001] 0.171 ms
[002] 0.591 ms
[003] 7.008 ms
[004] 16.074 ms
[005] 17.490 ms
[006] 10.339 ms
[007] 18.348 ms
[008] 12.287 ms
[009] 15.211 ms
[010] 16.125 ms
[011] 16.110 ms
[012] 16.100 ms
[013] 2.887 ms
[014] 2.171 ms
[015] 2.223 ms
[016] 2.365 ms
[017] 15.942 ms
[018] 15.915 ms
[019] 15.896 ms
[020] 15.930 ms
[021] 15.950 ms
[022] 15.903 ms
[023] 15.602 ms
[024] 16.525 ms
[025] 15.597 ms
[026] 15.878 ms
[027] 15.908 ms
[028] 15.912 ms
[029] 16.046 ms
[030] 15.938 ms
*/
Console.ReadLine();
}
// 以下只是为了防止窗口无响应而已
var success = PeekMessage(out var msg, HWND.Null, 0, 0, PEEK_MESSAGE_REMOVE_TYPE.PM_REMOVE);
if (success)
{
// 处理窗口消息
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
Console.ReadLine();
LRESULT WndProc(HWND hwnd, uint message, WPARAM wParam, LPARAM lParam)
{
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
}
本文以上代码放在 github 和 gitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快
先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin db627230f9d565b3b6839a978582f872bcbacfd5
以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin db627230f9d565b3b6839a978582f872bcbacfd5
获取代码之后,进入 DirectX/D2D/JecekelbereLaiwharhowhelli 文件夹,即可获取到源代码
本文会经常更新,请阅读原文: https://blog.lindexi.com/post/dotnet-DirectX-%E9%80%9A%E8%BF%87%E5%8F%AF%E7%AD%89%E5%BE%85%E4%BA%A4%E6%8D%A2%E9%93%BE%E9%99%8D%E4%BD%8E%E8%BE%93%E5%85%A5%E6%B8%B2%E6%9F%93%E5%BB%B6%E8%BF%9F.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者收藏我的博客导航
本作品采用
知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接:
https://blog.lindexi.com
),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请
与我联系
。
无盈利,不卖课,做纯粹的技术博客
以下是广告时间
推荐关注 Edi.Wang 的公众号
欢迎进入 Eleven 老师组建的 .NET 社区
以上广告全是友情推广,无盈利