简介:一份基于Visual C++与DirectX开发的GUI用户界面完整工程,面向需要自定义游戏或多媒体应用界面的中高级C++开发者。项目利用Direct3D绕过传统Windows控件,实现按钮、窗口、列表、滑块等自绘UI组件,适合学习DirectX渲染与界面交互的底层实现。压缩包内含79个文件,以38个h头文件和36个cpp源文件为主,另有2个图标文件、1个Visual Studio解决方案、1个工程项目文件及1个资源脚本,整体大小121KB,结构清晰,便于按模块阅读。目前已有328人学习下载。源码不仅包含BattleTank主程序框架,还封装了CUIButton、CUIWindow、CUIListBox、CUISlider等常用控件类,以及CD3DGlobal、CD3DUtility等DirectX辅助工具,覆盖从Direct3D设备初始化、消息处理到控件绘制的完整链路。通过阅读和修改这些代码,可以快速掌握在DirectX环境下搭建自定义GUI的思路,并可直接复用其控件基类、动画速率控制和输入处理逻辑,为后续开发提供扎实基础。
1. 用 DirectX 画 GUI:不是炫技,是绕开 MFC 的另一种选择
打开一个名为 visual c++基于DirectX开发制作的GUI用户界面 的工程包,里面通常是一组 .cpp/.h 加资源文件,没有 MFC 的 CDialog,也没有 Qt 的 .ui。把 DirectX 和 GUI 放一起,容易误以为这是游戏启动器或特效演示。这类项目实际解决的是:想在 Win32 窗口里获得可控的渲染性能、逐像素自定义界面风格,又不想被控件库默认样式绑死时,用 Direct3D 建交换链,在 GPU 上把整个界面画出来。适合做启动器、监控面板、工具类软件的自绘皮肤。代价是没有现成控件,按钮、文本、输入框都要自己画、自己算命中。下面按「框架 → 绘制 → 交互 → 打包」四步把整套方案拆开讲。
2. 搭出渲染地基:窗口、交换链与渲染循环
2.1 创建宿主窗口:DirectX GUI 的地基仍是 Win32 窗口
DirectX 不能凭空往屏幕上画东西,它需要一个宿主窗口。别被「DirectX GUI」这个名字带偏,最底层仍然是创建一个普通的 Win32 窗口,自己写 WndProc 处理消息。常见做法是:注册窗口类、CreateWindow、然后进入消息循环。在 Visual Studio 里新建 C++ 项目时选「空项目」即可,不需要勾选 MFC 或 ATL,整套界面代码都是裸 Win32 加 DirectX。
// 注册窗口类:DirectX GUI 的宿主窗口 WNDCLASSEX wc = {}; wc.cbSize = sizeof(WNDCLASSEX); wc.style = CS_HREDRAW | CS_VREDRAW; // 尺寸变化时触发重绘 wc.lpfnWndProc = WndProc; // 全部消息由我们自己处理 wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName = L"DxGuiHost"; RegisterClassEx(&wc); // 创建窗口:客户区大小按 1280x720 起步 HWND hwnd = CreateWindow(L"DxGuiHost", L"DirectX GUI", WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME, // 去掉可拖动边框 CW_USEDEFAULT, CW_USEDEFAULT, 1280, 720, nullptr, nullptr, hInst, nullptr);这段代码有两个值得注意的地方。CS_HREDRAW | CS_VREDRAW 是为了在窗口尺寸变化时让系统通知我们重绘,因为交换链的后备缓冲尺寸和窗口客户区尺寸必须一致,否则画面会被拉伸。去掉 WS_THICKFRAME 是为了在早期阶段少处理一类「用户拖拽缩放」的消息;如果要做可缩放界面,后面必须接 WM_SIZE 里 ResizeBuffers 的逻辑,而不是让系统硬拉伸。
窗口创建这一步最容易犯的错是忘记调用 AdjustWindowRect 来计算带边框时的正确宽高。1280x720 传进去的是整个窗口的尺寸,客户区实际会比这小一圈,导致后备缓冲和客户区对不齐。我一般会先用 AdjustWindowRect 换算一次再创建,顺便把窗口标题改成自己的产品名。宿主窗口还有一个隐藏要求:不要在 WM_ERASEBKGND 里返回 TRUE 让系统擦背景,DirectX GUI 的背景完全由渲染循环负责,系统擦一下反而会闪一下白。
2.2 交换链与后备缓冲:先让一帧能 Present 出来
窗口就绪后,要创建三个核心对象:D3D11 设备(ID3D11Device)、设备上下文(ID3D11DeviceContext)和交换链(IDXGISwapChain)。设备负责创建纹理、渲染目标等资源;上下文负责记录并执行绘制命令;交换链负责把绘制结果交给显示链。这三件套是 DirectX GUI 的发动机,其它都是在这之上画的皮。
DXGI_SWAP_CHAIN_DESC sd = {}; sd.BufferCount = 2; // 双缓冲 sd.BufferDesc.Width = clientWidth; sd.BufferDesc.Height = clientHeight; sd.BufferDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; // 与 D2D 兼容 sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow = hwnd; sd.SampleDesc.Count = 1; // GUI 不开 MSAA sd.SampleDesc.Quality = 0; sd.Windowed = TRUE; sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH; HRESULT hr = D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, D3D11_SDK_VERSION, &sd, &swapChain, &d3dDevice, nullptr, &d3dContext); if (FAILED(hr)) { // E_INVALIDARG 通常是 Format 或 SampleDesc 配错 }参数说明:BufferCount 取 2 是双缓冲,GUI 场景足够;取 3 会多一帧延迟,点击响应反而变慢。Format 选 B8G8R8A8_UNORM 是因为后面接 Direct2D 时,D2D 默认像素格式就是 BGRA,选错会在创建 RenderTarget 时报错或出现颜色通道互换的诡异偏色。SampleDesc.Count 保持 1,MSAA 对 GUI 文字没有帮助,反而让屏幕坐标到纹理坐标的映射复杂化。如果 hr 返回 E_INVALIDARG,先检查这两项,别去怀疑显卡驱动。
2.3 渲染循环与消息泵:别让界面卡成幻灯片
DirectX GUI 不适合把渲染放在 WM_PAINT 里。WM_PAINT 是「有消息才触发」的低优先级消息,窗口拖动、菜单弹出时消息队列被占满,渲染就停摆,表现就是界面卡成幻灯片。常见做法是 PeekMessage 轮询加每帧主动渲染,这也是 DirectX 游戏和自绘工具类软件通用的消息泵写法。
bool running = true; while (running) { while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) { running = false; break; } TranslateMessage(&msg); DispatchMessage(&msg); } UpdateUI(dt); // 更新控件状态、动画 RenderFrame(); // 绘制全部控件 swapChain->Present(vsync ? 1 : 0, 0); // 1=等待垂直同步 }逻辑说明:PeekMessage 只处理已经到达的消息,不会像 GetMessage 那样阻塞线程,这样每一帧都能保证有一次 Present。UpdateUI 里做控件状态更新,RenderFrame 里做实际绘制,两者分离是为了以后方便把渲染挪到独立线程。Present 第一参数是同步间隔:GUI 设 1 能让帧率跟随显示器刷新率,避免画面撕裂;设 0 会跑满 GPU,白白增加发热和风扇噪音。GUI 场景我强烈建议固定为 1,除非你的界面里有需要极高刷新率的动画。
3. 绘制第一组控件:文本、矩形与精灵
3.1 Direct2D 与 DirectWrite:比 GDI 更平滑的文本方案
Direct3D 11 只画三角形,直接画文字等于自己抠字形再拼贴图,得不偿失。常见做法是在 D3D 之上叠一层 Direct2D:用 Direct2D 的 RenderTarget 画矩形、圆角、文本,底层还是 D3D 交换链在兜底。DirectWrite 负责文字排版和字体加载,两者配合是当前 Windows 自绘 GUI 的主流组合,抗锯齿效果比 GDI 好一个档次。
ID2D1Factory* d2dFactory = nullptr; D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, &d2dFactory); IDWriteFactory* dwFactory = nullptr; DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), (IUnknown**)&dwFactory); // 把交换链的 0 号缓冲包装成 D2D 可画的 DXGI Surface IDXGISurface* dxgiSurface = nullptr; swapChain->GetBuffer(0, IID_PPV_ARGS(&dxgiSurface)); D2D1_RENDER_TARGET_PROPERTIES rtp = {}; rtp.type = D2D1_RENDER_TARGET_TYPE_DEFAULT; rtp.pixelFormat.format = DXGI_FORMAT_B8G8R8A8_UNORM; rtp.pixelFormat.alphaMode = D2D1_ALPHA_MODE_IGNORE; rtp.dpiX = 96.0f; // 先按 96 DPI 创建,DPI 变化时重建 rtp.dpiY = 96.0f; ID2D1RenderTarget* d2dRT = nullptr; d2dFactory->CreateDxgiSurfaceRenderTarget(dxgiSurface, &rtp, &d2dRT);参数说明:alphaMode 用 D2D1_ALPHA_MODE_IGNORE 而不是 PREMULTIPLIED,因为窗口背景不透明,开 alpha 混合反而会让整个界面变成半透明。D2D1_FACTORY_TYPE_SINGLE_THREADED 在本方案里够用,能省一点同步开销;以后如果渲染线程和 UI 线程分离,再换成 MULTI_THREADED。dpiX/dpiY 这里先写 96.0f,等后面做 DPI 感知时在 WM_DPICHANGED 里重建这个 RenderTarget 并更新 DPI 值。
DirectWrite 这边要额外创建设置好的文本格式:字体族名、字号、字重。文本格式对象创建后不能改,字号变化时只能再建一份,所以常见做法是维护一个字体缓存 map<fontKey, IDWriteTextFormat*>。每帧 DrawText 前从缓存取,而不是每次新建,否则高帧率下 CPU 占用会异常高,这是很多人界面卡顿的隐藏原因。
3.2 矩形与圆角:控件骨架的两种画法
按钮、面板、输入框的背景,本质上都是填充矩形。Direct2D 提供 FillRectangle 和 FillRoundedRectangle 两条路径。圆角矩形要先创建几何对象再填充,普通矩形可以直接 Fill。圆角是自绘 GUI 的审美分水岭,直角界面和圆角界面的观感差距非常大,所以我把圆角作为默认方案。
// 圆角矩形的绘制参数:位置 + 圆角半径 D2D1_ROUNDED_RECT rr{}; rr.rect = D2D1::RectF(x, y, x + w, y + h); rr.radiusX = 6.0f; rr.radiusY = 6.0f; // 几何对象在初始化时创建一次,Resize 时才更新 ID2D1RoundedRectangleGeometry* geo = nullptr; d2dFactory->CreateRoundedRectangleGeometry(rr, &geo); // 每帧只换画刷颜色,不重建几何 d2dRT->FillGeometry(geo, hoverBrush); d2dRT->DrawGeometry(geo, borderBrush, 1.0f);参数说明:radiusX / radiusY 单位是像素,4 到 8 是按钮类控件的常见范围;超过高度一半会变成椭圆头,视觉上很怪。FillGeometry 和 DrawGeometry 分离,意味着高亮态只换画刷,不需要重新创建几何,这是自绘 GUI 控制 CPU 占用的关键习惯。还有一个细节:边框线宽 1.0f 在 125% 缩放下会落在像素边界上发虚,解决办法是结合 DPI 把线宽换算成物理像素,通常取 ceil(dipWidth * scale)。画刷不能每帧创建,初始化时把 Normal、Hover、Pressed、Disabled 四套颜色画刷全部建好。
3.3 图片与精灵:图标、Logo 和状态灯一次讲清
控件里的图标不适合走 D3D 纹理管线,用 WIC(Windows Imaging Component)解码成 ID2D1Bitmap 就够了。WIC 负责解析 PNG / JPEG,D2D 负责显示。状态灯这类需要频繁切换颜色的控件,用纯色几何就能模拟,不必依赖贴图,这样切换颜色只是换画刷,比换贴图快得多。
IWICImagingFactory* wicFactory = nullptr; CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&wicFactory)); // 解码链路:解码器 -> 帧 -> 格式转换 -> D2D 位图 IWICBitmapDecoder* decoder = nullptr; wicFactory->CreateDecoderFromFilename(L"icon.png", nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, &decoder); IWICBitmapFrameDecode* frame = nullptr; decoder->GetFrame(0, &frame); IWICFormatConverter* converter = nullptr; wicFactory->CreateFormatConverter(&converter); converter->Initialize(frame, GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom); d2dRT->CreateBitmapFromWicBitmap(converter, nullptr, &iconBitmap); // 绘制:opacity 参数做悬停透明度动画 d2dRT->DrawBitmap(iconBitmap, D2D1::RectF(x, y, x + iconSize, y + iconSize), 1.0f, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR);参数说明:DrawBitmap 的第三个参数是 opacity,做鼠标悬停渐入渐出时,每帧改这个值即可,贴图资源本身不用动。interpolationMode 有 NEAREST 和 LINEAR 两种:图标按原始尺寸显示时用 NEAREST 最锐利;需要缩放时用 LINEAR,否则出现明显锯齿。转换器目标格式固定用 32bppPBGRA,这是 D2D 最舒服的像素格式,用别的格式会在 CreateBitmapFromWicBitmap 时报格式不匹配。解码器创建后记得在控件销毁时释放,WIC 对象链上的每一环都要 Release,漏一个就是一次内存泄漏。
4. 把控件组织成界面:命中测试、焦点与布局
4.1 控件树与 ZOrder:谁盖住谁,谁先响应
DirectX GUI 的每个控件都是一个内存结构体,没有真正的窗口句柄。常见做法是定义一个 Control 基类,保存矩形区域、可见性、状态(Normal / Hover / Pressed / Disabled),然后按层级组织成树。绘制时从底层往顶层画,命中测试反过来,从顶层往下找。
enum class ControlState { Normal, Hover, Pressed, Disabled }; struct Control { Rect bounds; // 相对父控件的局部坐标 bool visible = true; ControlState state = ControlState::Normal; ControlType type; // 枚举:Button/TextBox/CheckBox... std::vector<Control*> children; std::function<void(MouseEvent)> onClick; };逻辑说明:children 的 bounds 保存局部坐标,绘制和命中时先变换到屏幕绝对坐标。最常见的翻车是弹窗里的按钮点不中——因为子控件坐标没做父级偏移,鼠标点击位置和控件实际绘制位置不在同一坐标系。我习惯在命中测试函数里统一做一次坐标变换:先递归求出控件绝对矩形,再和鼠标点比较,避免「绘制一套坐标、命中另一套坐标」的割裂。
ZOrder 的另一个隐形坑是 Disabled 状态:置灰的控件仍然要参与命中测试,但不响应点击,否则用户会点到被遮住的底层按钮。做法是在命中测试返回最顶层控件后,再检查 state 是否为 Disabled,是则直接丢弃本次点击。控件树设计时还要考虑模态弹窗:弹窗打开时,它下面的所有控件应当整体跳过命中测试,这比逐个置灰要高效得多。
4.2 焦点与键盘输入:Tab 切换要自己维护
自绘 GUI 没有系统级的焦点管理。常见做法是维护一个可聚焦控件列表和焦点索引,Tab 键循环移动,回车触发当前焦点控件的回调。文本框还需要自己处理字符输入、光标闪烁和选区。焦点系统是自绘 GUI 里最容易做漏的一块,很多人做完鼠标交互就把键盘丢了,结果用户测试时按 Tab 没反应。
case WM_KEYDOWN: if (wParam == VK_TAB) { // 在可聚焦控件列表里循环移动焦点 focusIndex = (focusIndex + 1) % focusableControls.size(); UpdateFocusVisual(); // 旧焦点回 Normal,新焦点进 Focus 态 } else if (wParam == VK_RETURN) { auto* c = focusableControls[focusIndex]; if (c && c->state != ControlState::Disabled) c->onClick(MouseEvent{}); } break;参数说明:键盘事件放在 WM_KEYDOWN 里判断,别在 WM_CHAR 里判断方向键——方向键会产生 WM_CHAR 消息且字符内容不可靠。文本框的光标用 500ms 定时器切换可见性即可,不必每帧重绘整个文本框。焦点切换时要注意 Disabled 控件不能接收焦点,所以 Tab 循环要跳过 state 为 Disabled 的项。UpdateFocusVisual 里要把旧焦点控件的绘制状态从 Focus 改回 Normal,否则界面上会同时出现两个「焦点框」。
消息分发还有一个常见的坑:窗口失去焦点后键盘状态残留。用户按住 Tab 切换到别的程序再切回来,GUI 里 Tab 会一直「按住不放」。做法是在 WM_KILLFOCUS 里清空键盘状态数组,并在 WM_ACTIVATE 恢复时重新初始化状态,这能避免大量诡异行为。
4.3 常用参数表与工程配置
| 参数 | 推荐值 | 说明 |
|---|---|---|
| BufferCount | 2 | 双缓冲足够,3 缓冲徒增延迟 |
| Present 同步间隔 | 1 | 跟随显示器刷新率,防撕裂 |
| DPI 感知模式 | PerMonitorV2 | 多分辨率缩放下文字不糊 |
| 编译字符集 | Unicode (/utf-8) | 避免中文控件名乱码 |
| 源文件编码 | UTF-8 with BOM | Visual Studio 旧版默认 ANSI 会乱 |
工程配置上还有两点要提前确认。CMake 项目里要把 CMAKE_MSVC_RUNTIME_LIBRARY 设成 MultiThreadedDLL,否则生成的 exe 静态链接 CRT,运行时和其他组件抢 VC 运行库版本,容易出怪问题。Visual Studio 直接建工程时,记得在项目属性里把「字符集」改成「使用 Unicode 字符集」,这能省掉后续所有 TCHAR 和宽窄字节转换的麻烦。DPI 感知建议在程序入口最早调用 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),并且在 WM_DPICHANGED 消息里重建交换链和全部依赖尺寸的画刷资源。
5. DirectX GUI 避坑:设备丢失、文本模糊与运行库
5.1 设备丢失:最小化再恢复就黑屏
现象:窗口最小化再恢复、或切换分辨率后,界面黑屏,甚至直接崩。原因:显卡设备在窗口最小化或分辨率变化时被重置,交换链后备缓冲失效,但代码还在用旧的 RenderTargetView 绘制。解决:在 WM_SIZE 里判断,若窗口宽高为 0,跳过渲染;恢复后用 ResizeBuffers 重建后备缓冲和 RenderTargetView。注意 ResizeBuffers 前必须确保所有绑定到后备缓冲的资源已释放,否则返回 DXGI_ERROR_INVALID_CALL。
提示:设备丢失是 DirectX GUI 和普通 GDI 程序最大的不同点,代码里任何一处假设「后备缓冲永远有效」都会在这里翻车。绘制函数里拿到的 RenderTargetView 指针,每次 Resize 后都要重新获取。
5.2 文本发虚:DirectWrite 的坐标要对齐像素
现象:同样一个字号,GDI 画出来清晰锐利,DirectWrite 画出来边缘发灰。原因:DirectWrite 是按亚像素精度排版的,基线没有对齐物理像素网格时,文字会被插值。解决:绘制坐标先做 floor 或 round 到整数;字号较大时开启 ClearType 抗锯齿模式。还有一个细节:同一段文字在 100% 缩放下锐利、在 150% 缩放下发虚,多半是 DPI 感知没开,系统把整个窗口放大了一倍,DirectWrite 的字体光栅化精度被拉伸破坏,这个要回到 4.3 的 DPI 配置去解决。
5.3 性能与内存:每帧创建资源是典型的翻车姿势
现象:帧率从 60 掉到 20,任务管理器里内存曲线持续上涨。原因:每帧调用 CreateSolidColorBrush、CreateRoundedRectangleGeometry 或重新解码图片,资源没有复用。解决:初始化时把所有固定颜色画刷、几何对象、位图缓存好;Resize 时只重建依赖尺寸的资源。把资源创建集中到一个 InitResources 函数里,并在 WM_SIZE 里只重建「尺寸相关」的那部分。这也是自绘 GUI 项目里最常见的血泪教训,定位手段是先用 Visual Studio 的调试器在 Create 函数上打条件断点,统计一帧内创建次数,就能看到是谁在偷 CPU。
5.4 中文乱码:字体族与字符集双保险
现象:按钮文字变成问号或方框,日志里字符完好。原因:源文件编码不是 UTF-8,或 DirectWrite 字体族名指向不支持中文的字体。解决:源文件用 UTF-8 with BOM,工程加 /utf-8 编译选项;字体族名用 L"Microsoft YaHei UI",不要用默认的 Segoe UI——它不包含中文字形,遇到中文直接降级成方块。这里的关键是 CreateTextFormat 里把字体族写对,以及源文件编码统一,两者缺一不可。只改字体族名不改编码,中文还是会乱;只改编码不改字体族名,中文变成方块。
5.5 新机器上双击没反应:运行库不是 SDK
现象:exe 在开发机上正常,换一台干净机器双击没有任何窗口,事件查看器报缺 d3dcompiler_47.dll 或 VCRUNTIME140.dll。原因:目标机器缺 Visual C++ Redistributable 或 DirectX 运行库组件。解决:打包时带上 VC_redist.x64.exe 并做静默安装;用类似 DirectX 修复工具的思路,在程序启动时检查 d3dcompiler_47.dll、dxgi.dll、d3d11.dll 是否存在,缺失就弹出提示。注意这里只需要运行库,不是整个 DirectX SDK。开发机上正常是因为你装了 Visual Studio,自带了一堆运行库;干净机器没有,这个差异最容易让新手误以为代码写错了。
6. 收尾验证:用自检页确认整套 GUI 没有白做
最后一件事不是打 zip 包,而是验证。我习惯在 GUI 里藏一个自检页:Ctrl+Shift+D 打开,显示当前帧率、后备缓冲尺寸、DPI 缩放值、VSync 状态,再加一个「恢复设备」按钮。这个页面逼着我把设备丢失、Resize、VSync 三条最容易出问题的路径暴露在界面上,每次改完一测便知。然后按固定清单过一遍:窗口最小化再恢复三次,确认不黑屏;125% 和 150% 缩放各启动一次,确认文字不发虚;长时间运行半小时,观察内存是否持续上涨;最后用任务管理器和 dumpbin /dependents 检查 exe 依赖,缺哪个运行库补哪个。
打包时我一般会把 VC_redist.x64.exe 和说明文件一起放进发布目录,并在 README 里写明最低运行环境:Win10 64 位、已装 Visual C++ Redistributable、显卡驱动支持 DirectX 11。自检页这个习惯看起来笨,但确实帮我避开了大多数「在我机器上好好的」的尴尬,也省去了在用户机器上反复试错的成本。希望帮到你。
本文还有配套的精品资源,点击获取