ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Visual C++ DirectX GUI开发:从渲染管线到控件实现与避坑指南

Visual C++ DirectX GUI开发:从渲染管线到控件实现与避坑指南 简介一套基于 Visual C 与 DirectX 构建的图形用户界面工程面向熟悉 C、希望利用 Direct3D 实现自定义 UI 的开发者。工程并非依赖传统 Win32 控件而是通过 DirectX 渲染管线自行绘制按钮、窗口、列表框等界面元素适合学习游戏 UI 或嵌入式界面开发时参考。压缩包共 79 个文件主要是 38 个头文件与 36 个源文件另外包含图标、.rc 资源脚本、Visual Studio 解决方案与项目文件整体约 121KB。代码按模块清晰划分CUI 基础库覆盖消息处理、位图、字体、文本框等通用控件engine 目录则提供 D3D 全局管理、键盘控制与工具类接近一个可复用的 UI 框架。已有 328 人浏览学习说明具备一定参考价值。通过阅读 BattleTank 等入口代码可以快速理解 DirectX 初始化、渲染循环、控件交互以及资源脚本的配合方式。配套资源还保留了完整的工程配置便于在 Visual C 环境下直接打开、编译与调试减少环境搭建成本。1. Visual C 基于 DirectX 开发制作的 GUI 用户界面先分清它是工具活还是炫技活解压visual c 基于 DirectX 开发制作的 GUI 用户界面.zip之前先想清楚一件事这套代码大概率不是给你看控件长什么样的而是给你看控件怎么被画进渲染管线里的。用 DirectX 做 GUI意味着窗口上的每个按钮、输入框、列表都是你自己用顶点和着色器拼出来的Win32 的按钮控件在这里根本不会出现。它的价值在于 UI 和 3D 渲染共用同一个交换链不来回切换绘制方式代价是文本、布局、输入焦点全得自己接管。帧率监视器、地图编辑器、游戏模组工具这类需要实时刷新界面的程序尤其适合这个方案。普通数据录入界面就别硬套了那是自找麻烦。2. DirectX GUI 的设计前提渲染循环、控件树与消息路由怎么协同直接拿 DirectX 写 GUI不是一句“把按钮画出来”这么简单。它背后是一整套与传统 Win32 编程完全不同的循环模型窗口消息只负责输入不负责绘制绘制完全由渲染循环驱动控件自己管状态和层级。这一章先讲清楚三件设计上的大事后面复现代码时才不会走偏。2.1 为什么绕开 GDIUI 和渲染管线共用交换链的账传统 Win32 GUI 走的是 GDIBeginPaint、FillRect、TextOut一套流程。GDI 是 CPU 光栅化绘制结果直接写到窗口 DC 上和 GPU 渲染是两条线。如果你的程序同时有 3D 场景和 2D 界面就得反复在“GDI 窗口”和“D3D 交换链”之间协调常见的现象是鼠标拖动界面时掉帧或者截图时界面和画面不同步。DirectX GUI 的思路是把 UI 当成 3D 场景里的几何体按钮是一个四边形文本是一张带 alpha 的纹理列表滚动是一次顶点位移。UI 和 3D 内容进入同一条渲染管线同一帧提交同一个 VSync 控制节奏。这么做最直接的好处是少了一次跨 API 调用的开销也不会出现两个绘制系统互相遮挡的问题。那为什么不干脆用 Direct2DD2D 在 D3D 设备上确实能画文本和几何但要额外维护一个ID2D1DeviceContext并且 D2D 的绘制命令和 D3D 自己要用的渲染状态之间经常需要切换 render target。放在游戏工具链里团队往往已经有现成的 D3D 绘制封装再多接一套 D2D 等于引入第二个黑匣子。直接用 D3D 写控件规则统一出了性能问题也好排查。2.2 一帧一提交DirectX GUI 控件树的最小结构传统 GUI 程序里窗口收到WM_PAINT才重绘界面只在需要时更新。DirectX GUI 不是这个逻辑每一帧从根控件开始递归调用所有可见控件的绘制函数把顶点数据提交给 GPU最后统一 Present。这个“一帧一提交”的模型决定了控件对象的组织方式必须像一棵树而不是一堆平铺的 HWND。struct Button { float x, y, w, h; // 控件在窗口坐标系下的矩形 const wchar_t* label; // 按钮文本 bool hovered; // 鼠标悬停状态绘制时改变颜色 void (*onClick)(void* ctx); // 点击回调由窗口消息触发 void Draw(ID3D11DeviceContext* ctx) { // 画背景 quad // 画文字 quad文字由 DWrite 批量生成到纹理再采样 } };这个结构里没有 HWND没有WM_COMMAND所有交互都走自有的命中测试和回调分发。Draw函数内部不直接调用DrawIndexed也行常见做法是先按绘制顺序收集渲染命令在帧末统一提交避免中途频繁切换渲染状态。绘制顺序必须和控件树的后序遍历一致后画的控件覆盖先画的控件这就引出下一节的问题——命中测试要反着来。2.3 Visual C 版本和 DirectX 运行库链接时先解决依赖拿到 zip 先看工程文件是哪个 Visual C 版本生成的。v140 对应 VS2015v141 对应 VS2017v142 对应 VS2019v143 对应 VS2022。工具集版本决定你用哪个 Windows SDK也决定编译后的 exe 需要装哪个版本的 Visual C Redistributable。常见翻车点是把 VS2022 编译的 exe 拿到只装了 VS2015 运行库的机器上跑启动就报缺 VCRUNTIME140.dll——其实 2015 到 2022 的运行库是向后兼容的装个新版本 vc_redist 就能覆盖旧的依赖不需要按年份来回换。另一个要命的问题是 DirectX SDK 的选择。老教程会让你装 DirectX SDK June 2010那个包里的 d3d11.h 和新版 Windows SDK 里的同名头文件会冲突。新工程根本不需要单独装 DirectX SDKd3d11.h、dxgi.h、d2d1.h、dwrite.h全在 Windows SDK 里。链接时按功能选库我一般这样配库文件何时链接说明d3d11.lib始终需要D3D11 设备、Context、资源接口的入口dxgi.lib需要交换链和输出枚举时IDXGIAdapter、IDXGISwapChain 接口dwrite.lib使用文本渲染时IDWriteFactory、IDWriteTextLayoutdxguid.lib使用 GUID 和 IID 时CLSID_DXGIFactory1 等标识符的定义链接脚本里把这三个库加进去基本就够跑一个最小 GUI 了。真正会编译失败的原因大多是头文件引用顺序或者两个 Windows SDK 版本路径混用项目属性里 Tools 平台版本和 SDK 版本保持一致就能避开。3. 用 visual c 把最小 DirectX GUI 窗口跑起来D3D11 初始化与点击事件环境理解到位下面直接落代码。这一章给的是能编译能跑的最小路径建窗口 → 初始化 D3D11 → 画一个按钮 → 鼠标点它触发回调。我没有贴完整工程文件因为每个项目的入口不同但这段代码可以直接嵌进你的 Win32 窗口程序里。3.1 D3D11 设备与交换链初始化四个绕不开的参数DXGI_SWAP_CHAIN_DESC scd {}; scd.BufferCount 2; scd.BufferDesc.Width 1280; scd.BufferDesc.Height 720; scd.BufferDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM; scd.BufferDesc.RefreshRate { 0, 0 }; // 窗口模式下调刷新率常被忽略传 0 最安全 scd.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; scd.OutputWindow hwnd; scd.Windowed TRUE; scd.SwapEffect DXGI_SWAP_EFFECT_DISCARD; scd.SampleDesc.Count 1; UINT flags 0; #ifdef _DEBUG flags | D3D11_CREATE_DEVICE_DEBUG; #endif D3D_FEATURE_LEVEL levels[] { D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0, D3D_FEATURE_LEVEL_9_3, }; D3D_FEATURE_LEVEL featureLevel; ID3D11Device* device nullptr; ID3D11DeviceContext* context nullptr; IDXGISwapChain* swapchain nullptr; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, // 默认适配器GUI 够用 D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, 4, // 4 表示上面的 feature level 数组项数 D3D11_SDK_VERSION, scd, swapchain, device, featureLevel, context );BufferCount 2就是双缓冲GUI 动画和拖动窗口时不会一卡一卡的。D3D11_SDK_VERSION这个宏只能传 SDK 内置常量自己改成别的数会导致创建设备返回E_INVALIDARG属于第一类翻车点。D3D_FEATURE_LEVEL数组必须从高到低排列驱动会从中选一个它支持的最高级别没有 9_3 兜底的话在 Win7 老机器上初始化会整个失败。D3D11_CREATE_DEVICE_DEBUG只在 Debug 构建下开它会通过调试输出窗口把错误明细打出来GUI 问题比 3D 场景更难肉眼定位这个标志能省很多排查时间。发布版本记得把这段去掉否则带上额外的校验逻辑老 GPU 上可能拖慢初始化速度。3.2 画一个可点击的按钮顶点缓冲、着色器与矩阵GUI 里的一张按钮图本质是两个三角形拼成的四边形。下面定义顶点结构并创建顶点缓冲struct Vertex { float x, y, z; // 位置屏幕像素坐标转 NDC 后在着色器里做 float u, v; // 纹理坐标给按钮背景贴图用 }; Vertex verts[4] { { x, y, 0, 0, 0 }, { x w, y, 0, 1, 0 }, { x w, y h, 0, 1, 1 }, { x, y h, 0, 0, 1 }, }; UINT indices[] { 0, 1, 2, 0, 2, 3 }; D3D11_BUFFER_DESC bd {}; bd.ByteWidth sizeof(verts); bd.Usage D3D11_USAGE_DYNAMIC; // 按钮位置会变用 Dynamic 方便上传 bd.BindFlags D3D11_BIND_VERTEX_BUFFER; bd.CPUAccessFlags D3D11_CPU_ACCESS_WRITE; device-CreateBuffer(bd, nullptr, vertexBuffer);顶点缓冲的Usage参数很讲究。GUI 按钮位置不动时用D3D11_USAGE_DEFAULT就行性能和带宽都最好但窗口能拖动、按钮能换位置时D3D11_USAGE_DYNAMIC配合Map/Unmap每次移动只更新几个字节比重新 CreateBuffer 便宜得多。ByteWidth是 4 个顶点乘以结构体大小别漏算。着色器这边不用引入完整 3D 矩阵GUI 只需要把屏幕像素坐标换算成 NDC 坐标ndc.x (x / width) * 2 - 1ndc.y -(y / height) * 2 1y 取反是因为屏幕坐标原点在左上角NDC 原点在中心。像素着色器只输出颜色和 alpha不需要光照也不需要采样器状态处理。编译目标用vs_4_0而不是vs_5_0兼容性会好很多功能上对 GUI 没有区别。创建着色器时最容易漏的是InputLayout。顶点数据里有 2 个元素POSITION 是 3 个 float偏移 0TEXCOORD 是 2 个 float偏移 12 字节。InputLayout 必须和顶点结构完全匹配否则 DrawIndexed 直接画不出东西或者画出来的位置偏移错乱但设备层不报错这种问题最难查。3.3 鼠标命中检测从消息循环到控件树的反向遍历按钮画出来了接下来处理点击。在窗口过程里拦截WM_LBUTTONDOWN取光标位置然后遍历控件树命中测试case WM_LBUTTONDOWN: { POINT pt; pt.x (short)LOWORD(lParam); pt.y (short)HIWORD(lParam); // 后绘制的控件在上层命中检测必须反向遍历 for (auto it controls.rbegin(); it ! controls.rend(); it) { auto c *it; if (pt.x c.x pt.x c.x c.w pt.y c.y pt.y c.y c.h) { c.onClick(ctx); break; } } break; }lParam里的鼠标坐标要先用LOWORD和HIWORD拆开再转 short直接强转指针在 64 位下会出错。控件树顺序和绘制顺序保持一致命中检测反过来从最后一个控件开始往前找这样最上层控件优先响应。超过两个控件重叠时这个规则能保证用户看到什么就能点什么。注意这里有个细节如果控件带圆角或者 alpha 纹理矩形命中测试会把空白区域也算进去。商业游戏工具链里会再做一层 alpha 检测读鼠标位置的像素 alpha 值小于阈值就继续往下遍历。最小实现先不做但绘制顺序和遍历顺序的一致性必须一开始就定好不然后面加控件结构时容易乱。4. DirectX GUI 工程避坑记录初始化失败、中文乱码与运行库闪退DirectX GUI 和普通 MFC 程序最大的区别在于它依赖了运行库、系统组件、驱动三套东西任何一环出问题都是启动闪退而且报错不明显。这一章的五个踩坑记录全是能复现的真问题4.1 初始化返回 E_INVALIDARGFeature level 和 Win7 兼容性现象调用D3D11CreateDeviceAndSwapChain返回 0x80070057也就是E_INVALIDARG控制台没有任何输出。原因有两类。第一类是参数本身写错最常见是D3D11_SDK_VERSION传了别的数或者DXGI_SWAP_CHAIN_DESC里BufferDesc的宽高传成 0。第二类是系统环境的坑Windows 7 原版系统没有装平台更新补丁时D3D11 的硬件 feature level 无法正确枚举而你代码里的 features 数组最低只写到 10_0于是找不到可用的级别设备直接创建失败。解决先改参数把 feature level 数组加上D3D_FEATURE_LEVEL_9_3兜底9_3 几乎能在所有支持 D3D10 的显卡上跑起来代价是部分渲染特性用不了GUI 这种 2D 四边形完全不受影响。Windows 7 平台更新补丁 KB2670838 装上以后11_0 和 10_1 的枚举就正常了。如果是在远程桌面或者无 GPU 环境调试把D3D_DRIVER_TYPE_HARDWARE换成D3D_DRIVER_TYPE_WARP它用软件模拟 D3D11功能齐全但速度慢只适合验证逻辑。4.2 中文全部变成方框DWrite 字体回退链没配现象按钮和标签上的英文正常中文全部显示成“口口”而不是乱码。GDI 的TextOut没这个问题但用 DWrite 渲染时出现了。原因你创建一个IDWriteTextFormat时传了LMicrosoft YaHeiDWrite 在系统里找不到该字体却不会自动给你回退到宋体或黑体而是用占位方框把每个字符顶掉。Win7 默认没有微软雅黑只有宋体和新宋体Win10/Win11 有微软雅黑但缺少某些生僻字形照样出方框。DWrite 的回退机制不像 GDI 会自动选字体必须主动设置字体回退链。解决用IDWriteTextLayout的SetFontFamilyName分段设置或者实现一个IDWriteFontCollection把系统中存在的字体名逐个添加进候选集合。优先级我常用“微软雅黑 → 黑体 → 宋体 → SimSun-ExtB”。更彻底的方案是把字体文件子集化和 exe 放一起代码里用AddFontFileReference加载私有字体集合和系统字体完全解耦。顺带记得把TextLayout的SetWordWrapping设为DWRITE_WORD_WRAPPING_NO_WRAP否则中英文混排时换行位置会很奇怪。4.3 0xc000007b 运行时闪退vc redist 架构不匹配现象解压 zip 后双击 exe直接弹“应用程序无法正常启动 0xc000007b”有时候先报缺vcruntime140.dll或msvcp140.dll。原因0xc000007b 这个错误码本身就是架构不匹配的代名词。32 位 exe 加载了 64 位的运行库或者反过来都会触发它。DirectX GUI 工程如果用了 Debug 版运行库目标机器没装对应版本报错现象就是启动即闪退没有任何 UI 提示。解决先看一眼 exe 的平台任务管理器进程列表的“平台”列或者dumpbin /headers输出的machine字段能确认。然后装对应架构的 Microsoft Visual C Redistributable——x86 程序装 x86 的 vc_redistx64 程序装 x64 的。2015 到 2022 年的运行库都是同一套下载页面装最新的就行它会覆盖所有旧版依赖。别逮住“DirectX 修复工具”一类工具乱装它们修的 d3d 系列系统组件d3d11.dll和dxgi.dll在系统目录里一般完好这个报错查不到 VC 运行库就是白费力气。4.4 窗口拉伸后画面撕裂处理 WM_SIZE 时 ResizeBuffers 的参数陷阱现象拖动窗口右边框把界面拉大背景变成黑色或者花屏调试输出里出现DXGI_ERROR_INVALID_CALL。原因WM_SIZE消息来了以后交换链的缓冲区尺寸没跟着窗口客户区变。很多人在初始化时给DXGI_SWAP_CHAIN_DESC写死了 1280×720窗口拉大到 1920×1080Present 时后缓冲区还是旧尺寸和窗口目标尺寸不匹配显卡干脆拒绝提交画面。直接调用ResizeBuffers又容易踩第二个坑——BufferCount参数传成了 3和创建交换链时设置的 2 不一致内部资源复用时失败。解决WM_SIZE里拿到新的cx和cy先把后缓冲区的RenderTargetView释放掉再调用swapchain-ResizeBuffers(2, cx, cy, DXGI_FORMAT_R8G8B8A8_UNORM, 0);然后重新创建 RenderTargetView。第四个格式参数必须和创建交换链时一致传DXGI_FORMAT_UNKNOWN可以让系统沿用旧格式更保险。窗口最小化时cx和cy可能是 0这种时候要直接跳过 ResizeBuffers等WM_SIZE再带一次真实尺寸回来。注意DXGI_SWAP_EFFECT_DISCARD下 ResizeBuffers 的最后一个 flags 传 0 就够传DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH反而会触发不必要的全屏模式切换。4.5 退出程序崩溃D3D11 对象释放顺序现象关闭窗口时机子不崩溃但鼠标一关就闪退或者进程退出时调试输出报错指向 d3d11.dll 里的访问冲突。原因D3D11 对象的释放顺序不是谁先创建谁先释放而是按依赖关系逆序释放。ID3D11DeviceContext引用了ID3D11Device交换链引用了后缓冲区后缓冲区的 RenderTargetView 又挂在设备上下文上。如果你先把swapchain释放了Context 再想操作后缓冲区就已经指向悬空内存。另一个更容易忽略的点是窗口的WM_DESTROY在渲染循环还在跑时就触发了释放资源的代码和绘制代码并发执行。解决退出流程固定在一条路径上。先用一个bool shuttingDown标志通知渲染循环停止等 Present 返回后退出循环然后context-ClearState()再context-Flush()把命令队列排空最后按“RenderTargetView → swapchain → context → device”的顺序依次释放。用ComPtrT管理资源能摆脱手动 Release 的麻烦但解决不了渲染循环访问已释放资源的时序问题——关窗口时先停循环再释放对象这个顺序是硬性的。5. 进阶九宫格控件与帧级性能验证让 DirectX GUI 从能跑到好用基础 GUI 跑通只是第一步。真实工程里按钮要适配不同分辨率文本要常驻不重排性能要有客观指标。给你两个最实用的进阶技巧。九宫格是 DirectX GUI 按钮贴图的基本功。按钮背景是带圆角的 PNG直接整图缩放会导致圆角变形、边界发虚。正确做法是把图切成 9 块四个角保持原尺寸四条边只在对应方向拉伸中间区域整块拉伸。实现时给每个按钮生成最多 9 个四边形每个四边形带不同的纹理坐标范围。角的大小固定为 16 像素水平和垂直边的 UV 范围分别按宽度、高度动态算。看起来复杂但核心代码就是一个四重嵌套循环性能开销远小于让美术导出多分辨率切图。纹理过滤用D3D11_FILTER_MIN_MAG_MIP_LINEAR拉伸时不会出现锯齿。性能验证不要靠感觉用时间戳说话。在 Present 前后夹一段计时LARGE_INTEGER freq, start, end; QueryPerformanceFrequency(freq); QueryPerformanceCounter(start); context-DrawIndexed(6, 0, 0); swapchain-Present(1, 0); // 第二参数 1 表示垂直同步等待 QueryPerformanceCounter(end); double ms (end.QuadPart - start.QuadPart) * 1000.0 / freq.QuadPart;这段测的是 CPU 提交耗时加 Present 阻塞时间。如果数字一直压在 16.7ms 附近说明瓶颈是 VSync 等待不是你的 GUI 代码慢需要把Present(0, 0)关掉垂直同步再测得到裸帧性能。20 个控件的界面CPU 提交耗时压在 1~2ms 以内才是健康的。Debug 版下开着 D3D11 Debug Layer 会有额外校验开销性能测试一定用 Release 构建否则数据失真。早期我做帧率监视器时文本渲染每帧重置IDWriteTextLayoutCPU 占用直接吃掉一个核心后来发现静态标签根本不需要每帧重新排版把布局对象缓存起来只在文本变化时重建CPU 耗时从 10ms 降到了 0.4ms。DirectX GUI 的性能问题大多不是画不出来而是重复提交根本没有变过的资源。这套方案的坑不在代码写不写得出来在环境依赖和释放顺序这些看不到的地方踩过一次自然就长记性了。希望帮到你。本文还有配套的精品资源点击获取
返回列表