
简介一份基于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 LDxGuiHost; RegisterClassEx(wc); // 创建窗口客户区大小按 1280x720 起步 HWND hwnd CreateWindow(LDxGuiHost, LDirectX 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 保持 1MSAA 对 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 这边要额外创建设置好的文本格式字体族名、字号、字重。文本格式对象创建后不能改字号变化时只能再建一份所以常见做法是维护一个字体缓存 mapfontKey, 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 纹理管线用 WICWindows Imaging Component解码成 ID2D1Bitmap 就够了。WIC 负责解析 PNG / JPEGD2D 负责显示。状态灯这类需要频繁切换颜色的控件用纯色几何就能模拟不必依赖贴图这样切换颜色只是换画刷比换贴图快得多。IWICImagingFactory* wicFactory nullptr; CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory)); // 解码链路解码器 - 帧 - 格式转换 - D2D 位图 IWICBitmapDecoder* decoder nullptr; wicFactory-CreateDecoderFromFilename(Licon.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::vectorControl* children; std::functionvoid(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 常用参数表与工程配置参数推荐值说明BufferCount2双缓冲足够3 缓冲徒增延迟Present 同步间隔1跟随显示器刷新率防撕裂DPI 感知模式PerMonitorV2多分辨率缩放下文字不糊编译字符集Unicode (/utf-8)避免中文控件名乱码源文件编码UTF-8 with BOMVisual 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 编译选项字体族名用 LMicrosoft 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 里藏一个自检页CtrlShiftD 打开显示当前帧率、后备缓冲尺寸、DPI 缩放值、VSync 状态再加一个「恢复设备」按钮。这个页面逼着我把设备丢失、Resize、VSync 三条最容易出问题的路径暴露在界面上每次改完一测便知。然后按固定清单过一遍窗口最小化再恢复三次确认不黑屏125% 和 150% 缩放各启动一次确认文字不发虚长时间运行半小时观察内存是否持续上涨最后用任务管理器和 dumpbin /dependents 检查 exe 依赖缺哪个运行库补哪个。打包时我一般会把 VC_redist.x64.exe 和说明文件一起放进发布目录并在 README 里写明最低运行环境Win10 64 位、已装 Visual C Redistributable、显卡驱动支持 DirectX 11。自检页这个习惯看起来笨但确实帮我避开了大多数「在我机器上好好的」的尴尬也省去了在用户机器上反复试错的成本。希望帮到你。本文还有配套的精品资源点击获取