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的“神经”。玩家点了一下鼠标,怎么知道点到了哪个按钮?

我的做法是:

  1. 从根节点开始,遍历所有可见的Widget。
  2. 检查鼠标位置是否在Widget的包围盒内。
  3. 如果在,就调用该Widget的onEvent方法。
  4. 如果该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占用奇高。后来加了脏标记,只在setPositionsetSize时标记需要重算,性能直接提升了3倍。

SVG:UI系统的整体架构图

下面这张图展示了UI系统的核心模块和它们之间的关系。我习惯用这种图来梳理思路,写代码时心里就有谱了。

游戏引擎UI系统架构图 业务层(Game UI) 血条 | 背包 | 对话框 | 小地图 | 技能栏 逻辑层(UI Framework) Widget树管理 | 事件分发 | 布局计算 | 脏标记 动画系统 | 输入映射 | 焦点管理 渲染层(Render Backend) 顶点缓冲 | 纹理管理 | 批处理 | 裁剪 数据流方向:业务层 → 逻辑层 → 渲染层

实战中的几个坑

最后分享几个我踩过的坑,希望能帮你少走弯路。

问题 表现 解决方案
事件穿透 点击按钮,背后的面板也响应了 事件消费后立即停止传播,不要继续遍历
布局抖动 窗口大小变化时,UI元素来回跳动 布局计算加锁,避免在渲染线程中修改
内存泄漏 频繁创建销毁UI,内存只增不减 用对象池管理频繁复用的UI元素
渲染闪烁 UI更新时出现短暂撕裂 双缓冲渲染,或者使用脏区域局部更新

我个人习惯在项目初期就引入单元测试,专门测UI的事件分发和布局计算。别看这步麻烦,后期改UI逻辑时,它能帮你省下大量调试时间。

好了,UI系统的基础框架就聊到这儿。代码量不大,但设计上需要多花心思。记住一句话:好的UI系统,是让业务层写代码的人感觉不到它的存在。

公众号:蓝海资料掘金营,微信deep3321