95、C++项目实战:游戏引擎(UI系统)
做游戏引擎,UI系统是绕不开的硬骨头。我早年参与过一个商业引擎的UI模块重构,那会儿真是踩坑无数。今天咱们就聊聊,怎么用C++搭一个轻量但可用的游戏UI系统。
说白了,UI系统就是管理“怎么画按钮、怎么响应点击、怎么切换界面”的一套机制。你想想看,没有UI的游戏,玩家连开始游戏都点不了,那还玩啥?
UI系统的核心架构
我个人习惯把UI系统拆成三层:
- 底层渲染层:负责把UI元素画到屏幕上。通常基于OpenGL/DirectX,或者用现成的2D渲染库。
- 中间逻辑层:管理UI元素的树形结构、事件分发、布局计算。
- 上层业务层:具体到游戏里的血条、背包、对话框等。
咱们今天重点讲中间逻辑层,因为这是最通用、也最容易出问题的地方。
核心设计原则:UI元素应该是一棵树。每个节点(Widget)有自己的位置、大小、可见性,以及子节点列表。事件从根节点向下分发,或者从叶子节点向上冒泡。
UI节点基类的设计
先看一个基础的Widget基类。嗯,这里要注意,不要一开始就搞太复杂,够用就行。
class Widget {
public:
Widget() = default;
virtual ~Widget() = default;
// 位置和大小
void setPosition(float x, float y);
void setSize(float w, float h);
Rect getBounds() const;
// 父子关系
void addChild(std::shared_ptr<Widget> child);
void removeChild(Widget* child);
Widget* getParent() const;
// 生命周期
virtual void onUpdate(float deltaTime);
virtual void onRender();
virtual bool onEvent(const Event& event);
// 可见性
void setVisible(bool visible);
bool isVisible() const;
protected:
float m_x = 0, m_y = 0;
float m_width = 100, m_height = 100;
bool m_visible = true;
Widget* m_parent = nullptr;
std::vector<std::shared_ptr<Widget>> m_children;
};
我在项目中遇到过一个问题:很多人喜欢把m_children存成裸指针,结果析构时内存泄漏。后来我统一改用shared_ptr,配合weak_ptr处理父指针,才彻底解决。
事件系统:从点击到响应
事件系统是UI的“神经”。玩家点了一下鼠标,怎么知道点到了哪个按钮?
我的做法是:
- 从根节点开始,遍历所有可见的Widget。
- 检查鼠标位置是否在Widget的包围盒内。
- 如果在,就调用该Widget的
onEvent方法。 - 如果该Widget处理了事件(返回true),就停止传播。
代码大概长这样:
bool Widget::dispatchEvent(const Event& event) {
if (!m_visible) return false;
// 先检查自己
if (containsPoint(event.mouseX, event.mouseY)) {
if (onEvent(event)) {
return true; // 事件被消费
}
}
// 再分发给子节点(从后往前,让上层Widget优先)
for (auto it = m_children.rbegin(); it != m_children.rend(); ++it) {
if ((*it)->dispatchEvent(event)) {
return true;
}
}
return false;
}
小技巧:子节点遍历用逆序,这样后添加的Widget(通常在上层)会优先响应事件。这符合“Z-order”的直觉。
布局系统:别让按钮挤在一起
布局计算是个麻烦事。我早期写过一套“绝对定位”的UI,后来发现改个分辨率就得重调所有坐标,太痛苦了。
后来我引入了简单的布局器:
- 垂直布局:子节点从上到下排列,自动计算Y偏移。
- 水平布局:子节点从左到右排列。
- 网格布局:按行列排列,适合背包格子。
每个布局器重写onLayout()方法,在父节点大小变化时重新计算子节点位置。
class VBoxLayout : public Widget {
public:
void onLayout() override {
float yOffset = 0;
for (auto& child : m_children) {
child->setPosition(0, yOffset);
yOffset += child->getHeight() + m_spacing;
}
// 更新自身高度
setSize(m_width, yOffset);
}
private:
float m_spacing = 4.0f;
};
你想想看,如果每个UI都手动算坐标,那游戏里的背包界面得写多少代码?布局器就是干这个的。
渲染优化:别每帧都重新画
UI渲染有个常见坑:每帧都重新生成顶点数据。我见过一个项目,UI只有十几个按钮,结果DrawCall飙到200多,帧率直接掉到30。
优化思路其实很简单:
- 脏标记:只有UI状态变化时才重新生成顶点。
- 批处理:把相同纹理的UI元素合并到一个DrawCall。
- 裁剪:不在屏幕内的UI直接跳过渲染。
我曾经接手过一个项目,UI系统每帧都重新计算所有子节点的包围盒,导致CPU占用奇高。后来加了脏标记,只在setPosition或setSize时标记需要重算,性能直接提升了3倍。
SVG:UI系统的整体架构图
下面这张图展示了UI系统的核心模块和它们之间的关系。我习惯用这种图来梳理思路,写代码时心里就有谱了。
实战中的几个坑
最后分享几个我踩过的坑,希望能帮你少走弯路。
| 问题 | 表现 | 解决方案 |
|---|---|---|
| 事件穿透 | 点击按钮,背后的面板也响应了 | 事件消费后立即停止传播,不要继续遍历 |
| 布局抖动 | 窗口大小变化时,UI元素来回跳动 | 布局计算加锁,避免在渲染线程中修改 |
| 内存泄漏 | 频繁创建销毁UI,内存只增不减 | 用对象池管理频繁复用的UI元素 |
| 渲染闪烁 | UI更新时出现短暂撕裂 | 双缓冲渲染,或者使用脏区域局部更新 |
我个人习惯在项目初期就引入单元测试,专门测UI的事件分发和布局计算。别看这步麻烦,后期改UI逻辑时,它能帮你省下大量调试时间。
好了,UI系统的基础框架就聊到这儿。代码量不大,但设计上需要多花心思。记住一句话:好的UI系统,是让业务层写代码的人感觉不到它的存在。