ARTICLE DETAIL

资讯详情

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

Visual C++ + DirectX 自绘GUI框架源码拆解:从渲染循环到控件复用

Visual C++ + DirectX 自绘GUI框架源码拆解:从渲染循环到控件复用 简介这是一份基于 VC 与 DirectX 的图形用户界面示例工程面向具备 C 基础、想学习 Direct3D 渲染和自定义界面开发的读者。项目以 BattleTank 为主程序框架从 Direct3D 设备初始化、视口与投影设置、渲染循环到输入处理逐步搭建起包含按钮、列表框、滑块、光标、消息框、弹窗等控件的 CUI 界面库同时把 DirectX 全局管理、键盘控制、动画插值、位图与字体加载等底层能力封装成独立模块演示了如何突破传统窗口应用限制获得更动态的视觉表现。压缩包共 79 个文件以 38 个头文件与 36 个 C 源文件作为主体另有图标、工程与解决方案文件及资源脚本整体仅 121KB目录按 engine、UI 等层次划分便于检索复用。目前已有 328 人学习下载适合用来理解 C 与 DirectX 协同构建 GUI 的完整流程。1. 这套 Visual C DirectX 的 GUI不是游戏源码是一套能抄的界面框架很多人看到 DirectX 就默认是游戏引擎其实用 Direct3D 做 GUI 渲染器的桌面程序非常多——比如需要异形窗口、动态换肤、控件带动画的自研工具用 Win32 自绘能写到怀疑人生用 D3D 反而干净。这份名为 BattleTank 的 Visual C 工程核心并不是坦克游戏而是一整套完整的 DirectX GUI 用户界面框架引擎层管渲染界面层管控件消息层管交互。适合手里有 C 基础、想给传统 MFC/Win32 程序换一套现代界面或者想做游戏内菜单、HUD 的开发者在工程里直接抽取使用。它能回答一个很实际的问题不用 Duilib、不用 Qt纯手写 D3D 界面到底怎么组织代码。2. 先看清家底源码目录、工程入口与三条关键命名线这套资源拿到手是一个完整的 Visual Studio 工程第一件事不是急着编译而是把文件结构读明白。整个压缩包里的文件可以分成三组一组是工程与操作系统相关的入口文件一组是 engine 目录下的渲染引擎代码剩下的全部是 UI 目录下的控件与基础库代码。读懂这三组的边界后面处理编译错误和裁剪代码都会轻松很多。2.1 从 BattleTank.sln 到 CUIDisktop.cpp一份视觉上的文件分组先看工程入口这一层。BattleTank.sln和BattleTank.vcproj是解决方案与项目文件stdafx.h是预编译头Resource.h定义资源 IDBattleTank.rc是资源脚本BattleTank.ico和small.ico是大小两套图标。主程序文件是BattleTank.cpp它承担的角色是宿主窗口 渲染循环 业务逻辑挂载点。下面用一张表把这套 UI 框架里真正的主角分清楚分组代表文件职责D3D 引擎层engine/CD3DGlobal.h/cpp、engine/CD3DUtility.h/cpp设备创建、渲染状态、几何工具输入层engine/KeyControl.h/cpp键盘鼠标状态捕获与转换UI 基础库CUIBaseBitmap、CUIBaseFont、CUIBaseBuffer、CUIBaseBrush位图、字体、缓冲、画刷的自绘实现工具支撑CUIBaseFilePathLoader、CUIBaseStringTableLoader、CUIBaseErrorLogRecorder路径加载、多语言字符串表、错误日志控件集CUIButton、CUIListBox、CUISlider、CUITextBox、CUIPicture、CUICursor界面上的具体交互元素容器与核心CUICore、CUIDisktop、CUIControl、CUIMessageBox、CUIPopupDlg控件基类、桌面容器、消息盒与弹出框注意一个容易踩的细节顶层容器类在文件清单里写的是CUIDisktop.h/cpp拼写就是少了一个e不是CUIDesktop。工程内部所有头文件互相包含都按CUIDisktop这个名字来如果你自己写代码时按习惯拼成CUIDesktop编译直接找不到头文件。2.2 stdafx.h 与 Resource.h预编译头里的依赖地基这套工程能编译通过预编译头立了大功。stdafx.h把 Windows SDK、标准库、以及 D3D 的头文件全部收拢在一起stdafx.cpp只做一件事包含stdafx.h让它参与预编译。这样每次修改 UI 层代码时编译器不用重新解析一遍几千行的 Windows 头文件。看一下典型结构// stdafx.h : 标准系统包含文件的包含文件 #pragma once // 这里 WIN32_LEAN_AND_MEAN 会裁掉一部分不常用的 Windows 头加快编译 #ifndef VC_EXTRALEAN #define VC_EXTRALEAN #endif #include windows.h #include windowsx.h #include d3d9.h #include d3dx9.h #include string #include vector #include map // 工程内部公共头 #include CUIGlobal.h #include CUICore.h这段代码的用意很明确d3d9.h提供 IDirect3D9 和 IDirect3DDevice9 这两个核心接口d3dx9.h提供 D3DX 工具库比如加载图片的D3DXCreateTextureFromFile、创建字体的D3DXCreateFont。CUIGlobal.h和CUICore.h是 UI 层最先要被所有控件看到的公共声明。实际使用中如果你要往这套框架里加功能比如加入物理引擎或者网络库的头文件也应该加在stdafx.h里而不是散落在各个 cpp 文件顶部。2.3 类命名规则CUIBase 前缀与 CUI 前缀的边界这套代码的命名规律非常清晰理解它等于拿到阅读整份源码的索引。以CUIBase开头的类是不直接可见、只提供服务的基础设施典型如CUIBaseBitmap封装 D3D 纹理与 Surface 的创建销毁CUIBaseFont封装字体创建与文字绘制CUIBaseMessageHandle封装消息处理流程。以CUI开头且不带 Base 的类是看得见的交互单元比如CUIButton、CUIListBox、CUISlider。这两类之间的依赖关系通常是单向的控件类继承或组合基础类。比如CUIPicture内部持有CUIBaseBitmap对象来管理图片资源CUIButton的绘制逻辑里调用CUIBaseBrush画背景、调用CUIBaseFont画文字。我在改造这套框架时习惯再加一条约束业务代码只准依赖CUI控件类和CUICore不准直接碰CUIBase内部接口。理由是基础层改动频率高如果业务代码直接到处调用位图加载基础层一重构就要改几十个调用点。3. Direct3D 做 UI 的核心机制渲染循环、离屏表面与消息路由这套 GUI 和普通 Win32 窗口程序最大的差别在于界面上每一个像素都是 D3D 渲染出来的不是操作系统画出来的。所以消息循环、渲染循环、控件更新三者之间的协作方式是整个框架能不能流畅跑起来的关键。读完这一章你就能解释为什么窗口拉伸后界面对不齐这类问题的根因。3.1 CD3DGlobal 与渲染循环界面画在哪块画布上打开engine/CD3DGlobal.cpp核心是初始化 D3D9 设备并建立渲染循环。Direct3D 9 时代的经典初始化流程分为四步创建 IDirect3D9 对象、检查设备能力、填充 D3DPRESENT_PARAMETERS、创建设备。下面是去掉错误处理后的骨架// CD3DGlobal::InitDevice 简化示例接口名以工程内声明为准 bool CD3DGlobal::InitDevice(HWND hWnd, int width, int height) { // 1. 创建 D3D9 对象这是所有 D3D 操作的入口 m_pD3D Direct3DCreate9(D3D_SDK_VERSION); if (m_pD3D NULL) return false; // 2. 填充呈现参数窗口模式、后台缓冲格式、交换链行为 D3DPRESENT_PARAMETERS pp; ZeroMemory(pp, sizeof(pp)); pp.Windowed TRUE; // 窗口模式不切换全屏 pp.SwapEffect D3DSWAPEFFECT_DISCARD; // 交换链丢弃旧画面省显存 pp.BackBufferFormat D3DFMT_UNKNOWN; // 由系统决定颜色格式 pp.EnableAutoDepthStencil FALSE; // UI 不需要深度测试 pp.PresentationInterval D3DPRESENT_INTERVAL_ONE; // 开启垂直同步防撕裂 // 3. 创建设备BehaviorFlags 用软件顶点处理保证兼容性 HRESULT hr m_pD3D-CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, pp, m_pDevice); return SUCCEEDED(hr); }三个容易被忽略的参数D3DPRESENT_INTERVAL_ONE强制垂直同步界面上快速拖动窗口时不会出现撕裂线代价是帧率被锁在显示器刷新率对 GUI 完全够用D3DSWAPEFFECT_DISCARD是最快的交换方式但要求每一帧必须完整重绘整个后台缓冲所以这套框架里控件都是每帧全量重绘不做脏矩形D3DCREATE_SOFTWARE_VERTEXPROCESSING是老显卡兼容性最好的选择渲染 UI 的顶点量很小性能损失可以忽略。渲染循环的写法更关键。每个控件不是把绘制指令直接发到 GPU而是先绘制到后台缓冲最后统一 Present。主循环长这样// BattleTank.cpp 主循环简化 while (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) break; TranslateMessage(msg); DispatchMessage(msg); } // 每帧流程开始场景 - 清屏 - 绘制全部控件 - 结束场景 - 呈现 m_pDevice-BeginScene(); m_pDevice-Clear(0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(45, 45, 48), 1.0f, 0); m_UIDesktop.Draw(m_pDevice); // 递归绘制桌面容器下的所有控件 m_pDevice-EndScene(); m_pDevice-Present(NULL, NULL, NULL, NULL);这里m_UIDesktop.Draw是关键它遍历桌面容器的子控件链表对每个控件调用各自的绘制函数。控件内部如果持有纹理就把纹理绑定到渲染状态再画一个四边形如果只画纯色块就用CUIBaseBrush画一个填充矩形。整帧渲染是 CPU 和 GPU 交替工作所以消息处理不能阻塞在 Render 里超过 16 毫秒否则鼠标拖动会明显掉帧。3.2 位图缓存与纹理管理CUIBitmapMgr 为什么重要Direct3D 加载图片的常规做法是D3DXCreateTextureFromFile但这套框架里所有位图资源都走CUIBitmapMgr统一管理。原因很实际如果每个按钮都直接调 D3DX 加载自己的背景图同一个纹理会被显卡重复存储若干份显存开销成倍增长。CUIBitmapMgr内部维护了一张mapstring, LPDIRECT3DTEXTURE9以下载文件名为键首次加载放进缓存后续请求直接返回已有纹理的指针同时用引用计数控制释放时机。// CUIBitmapMgr::Load 简化示意 LPDIRECT3DTEXTURE9 CUIBitmapMgr::Load(const char* pszFilePath) { // 先查缓存命中直接返回避免重复走 D3DX 解析 std::mapstd::string, LPDIRECT3DTEXTURE9::iterator it m_Textures.find(pszFilePath); if (it ! m_Textures.end()) { return it-second; } // 未命中从文件加载纹理并放入缓存 LPDIRECT3DTEXTURE9 pTex NULL; D3DXCreateTextureFromFileA(m_pDevice, pszFilePath, pTex); m_Textures[pszFilePath] pTex; return pTex; }这块我在实际使用中吃过亏D3DX 默认生成的纹理尺寸是 2 的幂次方比如一张 100x100 的 PNG 会被扩展成 128x128如果绘制四边形时顶点坐标还是按 100x100 算图片会被拉高拉伸。处理办法有两个方向加载时指定D3DX_DEFAULT_NONPOW2强制保留原尺寸或者在纹理坐标计算时按实际尺寸和纹理宽高的比例换算。这套框架里CUIBaseBitmap对纹理坐标封装得比较薄建议后续自己扩展时把原始像素尺寸和纹理实际尺寸两个成员都记下来。3.3 消息路由鼠标点下去事件怎么找到按钮Win32 程序的消息机制是把 WM_LBUTTONDOWN 发给窗口过程而 DirectX GUI 没有子窗口概念所有控件都画在一张表面上。因此框架需要自己实现一套命中测试和消息分发逻辑。这部分对应的文件是CUIBaseMessageHandle.h/cpp与KeyControl.cpp。KeyControl负责在每一帧开始前调用GetDeviceState获取键盘和鼠标的原始状态记录鼠标当前位置和按键是否按下。然后CUIDisktop的鼠标事件处理函数做深度遍历从最上层的子控件开始逐个调用控件的PtInRect判断坐标是否落在控件范围内找到命中的控件后把鼠标消息投递给它。简化逻辑如下// CUIDisktop::OnMouseMove 简化逻辑 bool CUIDisktop::OnMouseMove(int x, int y) { for (int i m_Children.GetCount() - 1; i 0; i--) { CUIControl* pCtrl m_Children.GetAt(i); if (pCtrl-IsVisible() pCtrl-PtInRect(x, y)) { // 把坐标转换为控件内部坐标系再投递给控件 pCtrl-OnMouseMove(x - pCtrl-GetLeft(), y - pCtrl-GetTop()); return true; // 已命中停止向下分发 } } return false; }注意这里从GetCount() - 1倒序遍历意味着后添加的控件层级更高。这是自绘 GUI 框架里最常见的层级 z-order实现方式不需要维护复杂的树结构。消息一旦被上层控件消费下层控件就不会收到这也是点击透明区域却总是命中不了下方按钮时首先要怀疑的方向。4. 把这套 UI 接到你自己的工程最小接入与二次开发路线这一章解决一个最现实的问题我不想全用它的 BattleTank 模板只想把 UI 控件库摘出来放进自己的程序里怎么摘答案不是复制粘贴而是理解依赖边界后按三层接入引擎层单独编译、UI 层整体引入、业务层写自己的窗口初始化和消息循环。4.1 最小接入步骤头文件路径、链接库与初始化顺序首先要保证编译环境完整。这套工程基于 Direct3D 9需要安装 DirectX SDKJune 2010 版本比较稳妥并且确保工程属性里的 Include 目录和 Lib 目录指向正确位置。链接器依赖的库主要有d3d9.lib、d3dx9.lib、winmm.lib后面这个是为timeGetTime之类的时间函数准备的UI 动画帧率计算会用到。接入顺序我建议按下面五步走把UI目录和engine目录整个拷进自己工程保持相对路径不变。在自己的stdafx.h中追加CUICore.h、CUIDisktop.h、CUIGlobal.h三个头文件。创建CD3DGlobal对象在窗口创建完成后的WM_CREATE里初始化 D3D 设备。创建全局唯一的CUIDisktop容器把图片加载路径和字体文件路径告诉CUIBaseFilePathLoader。在主循环里按处理 Windows 消息 - 更新设备 - 绘制桌面 - Present的顺序驱动整个界面。初始化代码的最小骨架如下// 自己的窗口初始化中接入 UI 框架 CD3DGlobal g_D3D; CUIDisktop g_Desktop; BOOL OnInitDialog(HWND hWnd) { // 第 1 步启动 D3D 设备窗口句柄和宽高传进去 if (!g_D3D.InitDevice(hWnd, 1024, 768)) return FALSE; // 第 2 步把设备句柄交给桌面容器所有控件共享 g_Desktop.Create(g_D3D); // 第 3 步指定资源根目录图片和字体都从这里加载 g_Desktop.SetResourcePath(res); // 第 4 步创建一个按钮作为示例位置(20,20)大小(120,32) CUIButton* pBtn new CUIButton(); pBtn-Create(开始游戏, 20, 20, 120, 32); g_Desktop.AddChild(pBtn); return TRUE; }参数说明SetResourcePath指定的res目录是相对路径建议用绝对路径或者CUIBaseFilePathLoader里封装好的从 exe 所在目录计算资源路径的功能否则从资源管理器直接双击运行时图片会全部加载失败。CUIButton::Create的参数依次是文本、左上角 X、左上角 Y、宽度、高度控件的坐标是相对于父容器CUIDisktop的不是屏幕绝对坐标。4.2 事件绑定按钮点击后怎么通知业务层控件画出来只是第一步能响应点击才是交互的开始。这套框架的控件事件不是像 MFC 那样的消息映射表而是通过重写虚函数实现的。拿CUIButton来说鼠标按下又抬起并且两次都在按钮范围内才算一次完整点击。业务层处理点击的典型做法有两种直接继承CUIButton重写OnClick或者像上面的示例那样在OnInitDialog里把控件指针保存到成员变量然后在消息处理中查询状态。// 继承 CUIButton 处理点击事件 class CMainMenuButton : public CUIButton { public: void OnClick(int x, int y) { // 点击逻辑这里可以打开新界面、播放音效或切场景 StartGame(); } };继承方案的好处是每个按钮自带独立逻辑按钮多了不会在分发函数里写一长串 if-else。坏处是类数量膨胀如果一百个按钮就一百个类。我的习惯是折中数量在十个以内用继承十个以上用控件 ID 统一回调模式。具体做法是在Create时给控件绑定一个整数 ID消息循环里集中检查哪个控件触发了点击// 统一回调模式主循环或桌面容器里集中处理 int nId pBtn-GetID(); switch (nId) { case ID_BTN_START: OnStartGame(); break; case ID_BTN_OPTION: OnOpenOptionPanel(); break; }这种模式强调的是事件路由集中化对于菜单类界面特别好维护。注意OnClick触发时机是在鼠标抬起那一帧不是按下那一帧如果产品要求按下立即响应比如射击游戏的按钮需要自己重写OnMouseDown。4.3 自定义控件继承 CUIControl 的完整套路框架里自带的控件不够用时就要写新控件。新控件不需要关心 D3D 设备怎么创建的那是CUIControl基类已经处理好的事。自定义控件需要重写的核心方法只有四个Create负责传递控件尺寸和名称OnDraw负责把自己画到目标表面上OnMouseMove、OnMouseDown、OnMouseUp负责交互OnRelease负责释放自己持有的资源。// 自定义进度条控件骨架 class CUIProgressBar : public CUIControl { public: void Create(int x, int y, int w, int h) { SetPos(x, y); SetSize(w, h); m_fPercent 0.0f; } void SetPercent(float fPercent) { // 范围钳制到 0~1避免绘制时越界 m_fPercent (fPercent 0.0f) ? 0.0f : ((fPercent 1.0f) ? 1.0f : fPercent); } virtual void OnDraw(LPDIRECT3DDEVICE9 pDevice) { // 先画背景深灰色矩形 DrawRect(m_x, m_y, m_x m_w, m_y m_h, D3DCOLOR_XRGB(60, 60, 60)); // 再画前景按百分比计算宽度绿色填充 int fgWidth (int)(m_w * m_fPercent); DrawRect(m_x, m_y, m_x fgWidth, m_y m_h, D3DCOLOR_XRGB(0, 180, 90)); } private: float m_fPercent; };这个例子里DrawRect是封装在CUIBaseBrush里的函数实际工程里可以直接调用g_Desktop.GetBrush()-FillRect之类的方法。重点说一下GetPos和GetSize的坐标系自定义控件的OnDraw收到的坐标基准是父容器左上角m_x、m_y是相对于父容器的偏移。很多新手在控件内部写绘制代码时直接用屏幕绝对坐标结果按钮移动位置后进度条的填充区域对不齐背景原因就在这里。5. 避坑记录DirectX GUI 最常见的六个翻车现场这一段全部来自实际调试经验。DirectX 做界面问题集中出现在设备生命周期、中文渲染、坐标转换和运行库版本四类事情上。每一条我都按现象、原因、解决的格式记录下来照着排查能省下半天到一天的排查时间。5.1 现象窗口切出去再切回来界面花屏或全黑这个是最典型的 DirectX 程序问题原因在于 D3D9 设备在失焦、锁屏、显示器休眠后可能进入设备丢失状态。此时所有渲染 API 调用都会失败但程序并不知道依然按正常流程清屏、画图、Present结果就是黑屏或者残影。解决方式是渲染循环里必须检查Present的返回值如果是D3DERR_DEVICELOST就进入等待循环直到返回D3DERR_DEVICENOTRESET时调用Reset恢复设备。很多简化版代码不处理这一步所以只要窗口切换就出问题。// 设备丢失恢复逻辑 HRESULT hr pDevice-Present(NULL, NULL, NULL, NULL); if (hr D3DERR_DEVICELOST) { // 等待设备变成可重置状态期间不能做任何渲染 while (pDevice-TestCooperativeLevel() D3DERR_DEVICELOST) { Sleep(50); } // 可以重置了销毁所有纹理和 Surface再调用 Reset g_TextureMgr.ReleaseAll(); pDevice-Reset(g_PresentParams); }这里有个血泪教训Reset之前必须释放所有基于默认内存池创建的纹理、Surface、字体对象否则Reset会直接返回D3DERR_INVALIDCALL。重置完成后所有纹理要重新加载所以CUIBitmapMgr里的缓存一定要按照全局引用计数 统一释放来写而不是散落在各个控件里各管各的。5.2 现象按钮上的中文显示成方框或乱码Direct3D 本身不提供任何字体渲染文字完全靠字体纹理。CUIBaseFont内部如果用的是D3DXCreateFont那么中文字符能否显示取决于创建字体时指定的字符集。默认字符集是DEFAULT_CHARSET在某些系统语言版本下字体纹理里根本不包含中文字形渲染出来就是一排口字框。解决方式是显式指定GB2312_CHARSET或者SHIFTJIS_CHARSET这类东亚字符集同时把字体文件路径交给CUIBaseFilePathLoader去定位保证中文字体文件比如msyh.ttc或simhei.ttf确实存在于资源目录中。另外一个隐蔽坑如果代码是 ANSI 编码const char*字符串里的中文在字符集转换时可能已经乱掉建议全部使用宽字符L开始游戏传入。5.3 现象鼠标能画上去但按钮完全点不中这个问题的根因绝大多数不是绘制问题而是坐标不一致。Windows 在 DPI 缩放开启的情况下会给窗口发送缩放后的坐标而 D3D 的后台缓冲尺寸还是逻辑分辨率两者没做换算鼠标点下去的位置就整体偏移。解决方式是窗口创建时调用SetProcessDPIAware()或者在 WM_DPICHANGED 消息里重新计算控件布局让 D3D 的后台缓冲尺寸和窗口客户区实际像素尺寸保持一致。还有一个小概率原因CUIDisktop里控件添加顺序和绘制顺序不一致导致某个控件在 z-order 上遮挡了目标按钮点击事件被上层消费。排查方法是在OnMouseDown里加一行输出日志打印命中的控件 ID。5.4 现象编译报错error C2065: D3DXCreateTextureFromFileA : undeclared identifier这个报错说明头文件路径有问题。常见原因有几种DirectX SDK 没安装或者装了之后 Visual Studio 的 Include 目录配置不对更高版本的 Windows SDK 里也带了一份d3d9.h如果两个 SDK 的引用顺序反了编译器可能先找到了新版空壳头文件里面没有 D3DX 函数声明。解决方式是检查工程属性确保 DirectX SDK 的 Include 和 Lib 目录排在 Windows SDK 之前。另外一个相关问题是运行库版本microsoft visual c redistributable 版本和编译用的工具集不一致时在别的机器上跑会直接提示缺少 DLL或者弹应用程序无法正常启动。这事不玄学就是运行时环境没对齐装对应版本的 VC 运行库即可。如果不想排查环境优先用 system 自带的 dxdiag 和当前 Visual Studio 支持的最新 DirectX SDK 重装一遍。5.5 现象界面 FPS 只有十几帧CPU 占用却不高CPU 不高那就不是逻辑代码的问题而是渲染调用太慢。DirectX GUI 每帧都在调BeginScene、Clear、绘制顶点、Present这里的瓶颈通常出现在大量控件重复设置渲染状态上。比如 200 个按钮每个按钮都切换一次纹理GPU 就要在同样纹理上来回切换 200 次这种状态下帧率暴跌很正常。解决的常规手段是渲染前对控件按纹理指针排序同一纹理的控件集中绘制状态切换次数可以从几百降到几次。排序后在控件数量上千时帧率可以保持稳定这个技巧在下一章展开。有一个隐蔽点要特别提醒CUIBaseBitmap的纹理布局是新建的控件重绘时如果没有将纹理坐标和顶点坐标做好映射画面会偏一点这种问题很难通过效果图发现只能依靠 Debug 输出验证。5.6 现象UI 动画出现明显跳变尤其是滑动条和窗口渐入渐出CUIAnimationRate类负责计算动画进度原理是按时间戳取插值。它依赖一个稳定且高精度的时钟很多代码里是用timeGetTime返回毫秒但如果消息循环里偶尔被阻塞动画就会出现跳变。解决方式是不要用消息驱动的timeGetTime改成在渲染循环内部用QueryPerformanceCounter并把上一次时间戳记录在CUIAnimationRate内部每次获取进度时做差值运算。还有一点不能提但必须做好防护动画控件在Draw过程中如果外部修改了控件属性轻则花屏重则崩溃这种同步问题是玄学级别的难查建议所有控件修改操作统一放到帧开始前的更新阶段。6. 再进一步用排序绘制与 Debug 运行时把这套 GUI 调到商用级框架能跑起来只是第一步真正交付给别人用时性能和环境兼容性才是硬指标。这一章给两个马上能用的进阶技巧一个管性能一个管调试。6.1 按纹理分组排序绘制减少状态切换给CUIDisktop增加一个预处理步骤在调用每个控件OnDraw之前先把所有可见控件按照它们内部纹理的指针值排序然后遍历绘制。实现上可以在桌面容器里维护一个临时数组// 绘制前先排序伪代码示意 void CUIDisktop::Draw(LPDIRECT3DDEVICE9 pDevice) { std::vectorCUIControl* visibleCtrls; CollectVisible(visibleCtrls); // 收集所有可见控件 std::stable_sort(visibleCtrls.begin(), visibleCtrls.end(), [](CUIControl* a, CUIControl* b) { return a-GetTextureKey() b-GetTextureKey(); }); for (size_t i 0; i visibleCtrls.size(); i) { visibleCtrls[i]-OnDraw(pDevice); } }这个排序不是每次都全量开销很大的操作。在保证控件数量在 2000 个以内时stable_sort的开销是微秒级别的。关键收益在 GPU同纹理的绘制命令连续提交显卡不需要频繁切换纹理绑定状态。渲染管线的状态切换是最贵的操作之一从 300 次降到 30 次帧率提升可能有两倍。另一个连带优化是OnDraw内部不要每次都调用SetRenderState去改混合模式这些状态在绘制批次开始前统一设置一次。6.2 用 D3D Debug Runtime 和错误日志把崩溃挡在上线前开发阶段把 D3D9 设备创建参数改一下也能定位问题给CreateDevice的 BehaviorFlags 加上D3DCREATE_PUREDEVICE以外的调试标志或者在d3d9.h定义D3D_DEBUG_INFO。更直接的方案是创建IDirect3D9Ex接口时留意驱动返回的类型。这套框架的CUIBaseErrorLogRecorder打印调用级别很高每次鼠标、键盘事件都记录到日志文件上线后定位“按钮有时失灵”这类问题时特别有效。建议在日志文件里同时记录HRESULT返回值和控件坐标崩溃前最后一条日志往往就是距离问题最近的那一步。另外交付给用户时要把 VC 运行库版本对齐到目标机器。编译时选择 Multi-threaded DLL 运行库后分发包里带上对应版本的 microsoft visual c redistributable 安装包省去用户到处找运行库的麻烦。这块的处理思路和 DirectX 修复工具解决的是同一类问题让目标机器的系统组件和你的开发环境一致很多莫名其妙的我这能跑你那跑不了都是这个差异导致的。这套 DirectX GUI 框架不是拿来即用的成品库它的价值在于把D3D 渲染界面这套方案的工程结构完整地展示出来了。我从最初照抄BattleTank.cpp里的初始化代码到现在能在自己的项目里独立封装新控件中间踩过的坑大多记录在上一章的避坑清单里。那以后我每次新接一个 DirectX UI 的活儿都会强制自己先做三件事确认设备丢失恢复逻辑存在、确认字体加载走资源管理器、确认运行库版本和开发环境一致。这三件事不出问题整个界面框架就已经稳了一半。希望这篇拆解能帮你在复用这套代码时少走几步弯路把那半天到一天的时间省下来留给你自己真正要写的那部分业务逻辑。本文还有配套的精品资源点击获取
返回列表