CadenXc

C++ · Game Engines · Real-Time Rendering

0%

接入 Dear ImGui 之后,为什么还需要 UI 文档?

本文要回答的问题:

Dear ImGui 已经能够创建和显示控件,为什么游戏引擎仍然需要独立的 UI Document?

接入 ImGui 并不等于完成 UI 架构替换

这次 UI 重构最初的目标,是使用成熟的开源 UI 库 Dear ImGui,替代引擎中老旧的自研控件实现。Dear ImGui 持续维护,也适合工具界面,接入它可以减少维护控件绘制与交互的成本。

但在旧 UI 逐步退出后,我发现替换控件实现并不会自动为引擎带来 UI 资产编辑能力。用户需要创建、编辑和保存自己的 UI,这要求引擎用一个结构化模型保存节点层级、类型、属性和稳定 ID。这个模型就是 UI Document。旧编辑器创建的控件对象树,则同时承担了层级与属性、编辑预览、输入交互和序列化等职责。

旧控件树究竟承担了哪些职责

在旧 UI 架构里,引擎读取 XML 后,会根据节点类型创建容器、按钮等保留模式(retained-mode)控件对象,并组成父子树。这棵树负责布局、绘制、输入和事件。

旧 UI 编辑器打开 UI 文件时,也会创建一棵旧控件对象树,编辑区直接使用这棵树预览。用户保存时,再把控件树序列化回 XML。

旧编辑器必须先创建旧控件对象,才能编辑 UI 资产。Hierarchy(层级面板)根据对象间的父子关系展示层级,Inspector(属性查看器)直接修改这些对象的属性。预览和保存也依赖控件对象的属性与序列化接口,删除旧控件实现会同时破坏编辑、预览和保存。这种设计可以工作,本身并不是错误,但它使“使用 Dear ImGui 替换旧控件实现”的重构目标很难被单独推进。

即时模式 UI 何时需要文档模型

游戏引擎中较为固定的工具面板,可以由程序员直接在代码中调用 Dear ImGui,每帧重新提交界面。只有程序员维护这些界面时,通常不需要通用的 UI Document。

当引擎用户需要创建、编辑和保存自己的 UI 时,界面内容便不能全部写死在代码中。引擎需要一个结构化模型描述 UI 节点的层级、类型和属性,这份模型就是 UI Document。Dear ImGui 解决“这一帧如何绘制和交互”,UI Document 解决“这个 UI 资产由什么组成”。因此,并不是所有使用 Dear ImGui 的界面都需要 Document;需要由用户制作并持久化的 UI 资产才需要独立的 authoring model。

设计数据与运行时状态的区别

新架构先把 UI 文件解析为 Authoring Document,再从同一份 Document 创建一个或多个 Runtime Instance(运行实例):

1
2
UI 文件 → Authoring Document ─┬→ Runtime Instance A
└→ Runtime Instance B

Document 保存用户编辑的资产定义,例如节点类型、层级和初始可见性。这些属性为新实例提供初始值,不代表实例运行过程中的当前状态,也不应在普通运行过程中被脚本隐式修改。

Runtime Instance 保存 UI 在运行过程中变化的状态,例如当前是否可见、文本输入内容与光标位置、列表选择和滚动位置。每个 Runtime Instance 都与源 Document 和其他实例隔离,其中一个实例发生变化,不会影响其他实例。

这种隔离保护了资产的默认状态。一个按钮在运行时被脚本隐藏,并不表示用户希望它默认隐藏;再次创建实例时,它仍应从 Document 中记录的初始值开始。

各层必须遵守的约束

Document 是可编辑结构的唯一数据源

UI 的节点层级、可编辑属性和稳定身份只能由 Document 决定。

如果 Document 和预览对象都能修改结构,就会出现两个数据源;两边内容不一致时,保存、撤销和 Inspector 不知道应该相信谁。

编辑命令只修改 Document,布局快照与 Dear ImGui 预览只是根据 Document 生成的可丢弃视图,不能反向成为资产数据。

即使预览构建失败,Document 仍能继续编辑、撤销和保存;重新生成预览也不会改变资产内容。

稳定身份不能由列表索引代替

每个可编辑节点必须拥有不随重排改变的 stable ID。

列表索引只表示节点当前所在的位置。假设 Exit 按钮原本位于 index 1,在执行一条延迟操作前,列表头部插入了新节点,Exit 就会移动到其他索引。此时继续执行“修改 index 1”的操作,可能会修改另一个按钮。

因此,选择、撤销记录和延迟操作都应携带 stable ID,执行时再通过 ID 查找当前节点。节点已不存在时,应拒绝操作,不能退回使用旧索引并修改其他节点。

可以通过“记录操作—重排列表—执行操作”验证最终修改的仍是原节点。

序列化格式不是内存架构

XML 只负责将 Document 保存到文件,以及从文件恢复 Document。编辑器、预览和 Runtime 应依赖 Document,而不是直接操作 XML 节点。

如果把 XML 换成 JSON,但保存的信息完全相同,那么反序列化后的 Document 也应该相同,后面的编辑和运行逻辑不需要变化。

因此,更换文件格式不等于完成 UI 架构重构。

如何逐步迁移调用方,避免一次性切换

迁移旧 UI 时,我选择保留底层实现,先逐个迁移上层消费者;确认消费者引用归零且替代路径已经验证后,再删除旧实现。这种 consumer-first 策略可以让引擎在迁移期间保持可编译、可运行,而不是先破坏底层,再根据大量编译错误集中修复上层。

如果先删除底层,系统可能长时间无法编译,也就很难判断刚刚完成的修改是否正确,或新的错误究竟由哪一步引入。每迁移一个或一批消费者,都应恢复到可编译、可运行的状态,验证相关行为后再独立提交。

这符合小步重构的基本思想:每次只改变一小部分,及时验证,保持可以回退,并用独立提交保存阶段成果。旧实现只有在没有真实消费者之后,才成为可以安全删除的代码。

总结

Dear ImGui 解决每帧的绘制与交互,但不提供用户可编辑的资产模型。Document 保存可编辑、可持久化的资产定义,Runtime Instance 隔离每个运行实例的状态,而 XML 只是当前使用的序列化格式。

迁移过程采用 consumer-first 策略,使系统保持可编译、可运行和可回退。最终,除了 C++ 与脚本编译,还需要通过“创建、编辑、保存、重开、运行时加载、脚本交互”的完整人工链路验证实际行为。

参考资料