游戏引擎-自研 UI 子系统:交互、布局与渲染生命周期

文章目录

一个按钮画出来,并不难。真正困难的是让它始终表现正确。

打开设置页时,角色应该停止接受摇杆输入;关闭弹窗时,最后一次点击不能落到背后的技能按钮;修改一段文字后,布局和实际绘制不能各算一套尺寸;接入矢量动画以后,后续绘制不能莫名其妙地使用错误管线;某一帧被取消后,下一帧还要能正常恢复。

这些问题看起来分散,根源却相似:多个模块对状态、所有权和时间的理解不一致。

UI 从来不只是控件集合。它是一条把用户意图转换成屏幕像素的流水线,要同时连接内容生产、交互逻辑、布局计算、字体处理、资源管理与 GPU 执行。

本文以一套保留式、GPU 加速的 UI 架构为讨论对象,沿着“一次点击如何变成一帧画面”的过程,拆解其中最值得关注的原理与工程取舍。

一、自研的边界:掌握系统规则,复用成熟算法

讨论自研 UI,首先要回答的不是“需要多少种控件”,而是“哪些规则必须由自己掌握”。

页面什么时候创建和销毁?多个弹窗如何排列?谁决定输入是否继续传给玩法?文字变化如何使布局失效?第三方动画可以录制哪些命令?GPU 还在使用的资源由谁保管?

这些问题决定了 UI 能否稳定地融入宿主应用。它们应该有统一答案,而不是分散在各个控件、页面脚本和渲染回调里。

相比之下,Flex 布局、OpenType 字形整形、字体光栅化等算法,已经有成熟的基础库。合理的自研并不排斥复用,而是让这些基础库在清晰的职责边界内工作。

一种常见分工如下:

职责 可以采用的实现方式 应当保留的边界
页面、节点、交互与状态 自有运行时 不把业务逻辑写进底层控件
Flex 布局 Yoga 只计算盒子的尺寸与位置
字形整形 HarfBuzz 不把整形等同于完整段落排版
字形光栅化 FreeType 输出字形位图,不管理页面
矢量动画与状态机 Rive 管理局部内容,不接管主循环
命令、同步与资源生命周期 宿主渲染系统 保持最终提交权与回收规则统一

这里的原生 UI,是指直接使用应用自己的数据结构与图形接口,不依赖操作系统窗口控件。它也不意味着必须排斥其他 UI 技术:编辑器工具可以采用即时模式界面,面向用户的产品页面则可以采用保留式结构。不同场景可以共享资源和渲染设施,而不必使用完全相同的交互模型。

自研的价值,不在于每一行代码都由自己编写,而在于关键行为是否可控、可测、可演进。

二、先分清三种表示:文档、运行状态和绘制快照

UI 对象很容易越长越大:既保存资产字段,又记录鼠标状态,还持有布局对象和 GPU 纹理。最终,编辑器、业务线程和渲染线程都在访问同一个对象,任何局部修改都可能影响其他阶段。

解决这个问题,可以先把 UI 拆成三种表示。

2.1 文档描述“页面是什么”

文档保存可以被编辑、序列化和版本管理的内容:节点 ID、父子关系、布局属性、颜色、文字、资源引用、动作和数据绑定。

它不应该保存“鼠标刚刚按下”或“某个 GPU 上传正在等待完成”这样的瞬时状态。

2.2 运行状态描述“页面现在怎样运行”

运行时保存页面是否打开、节点当前值、焦点、指针捕获、滚动位置、动画时间和布局结果。

这是一种保留式模型:页面安装后,节点及其状态持续存在,业务通过修改节点驱动变化,而不必每帧重新声明整棵树。

需要注意,保留式 UI 保留的是节点与状态,不代表它天然实现了增量布局,也不代表 GPU 几何已经跨帧缓存。这些是独立的工程选择。

2.3 绘制快照描述“这一帧要画什么”

绘制阶段不应该为了取得一个按钮的颜色,重新访问正在响应输入的节点对象。更合适的方式是生成一份有序快照,复制本帧需要的矩形、文字、图片引用、裁剪和透明度。

整体关系可以表示为:

1
2
3
4
5
6
7
8
9
10
11
12
13
页面文档、页面清单
│ 解析、校验、实例化

输入 → UI 运行时:状态、交互、布局
│ 生成本帧绘制快照

有序绘制描述
│ 在约定的帧阶段交给消费者

渲染准备:字形、纹理、几何、批次
│ 录制到宿主命令缓冲

统一提交 → GPU 执行 → 完成后释放引用

快照的核心约束是:发布以后,它的含义不能再被下一次业务修改改变。

但只读快照不等于整个系统线程安全。它解决的是阶段之间的数据交接;资源缓存是否允许并发访问、命令缓冲由哪个线程录制,仍需另行设计。

三、页面资产:先建立约束,再创建对象

3.1 页面清单与页面内容各管一件事

页面清单回答的是:有哪些页面,它们属于哪个生命周期,显示在哪一层,是否阻断下层输入,是否允许用户关闭。

页面内容回答的是:页面内部有哪些节点,它们怎样排列、显示和响应操作。

例如,登录页可以属于启动阶段;导航栏属于玩法阶段且不阻断玩法;设置页属于模态层,需要阻断下层操作。绘制层级和输入策略应当分别表达,不能把“模态层”这一名称当作所有交互规则的替代品。

节点文档可以用下面的声明式伪代码表示。本文的伪代码只表达结构和流程,不对应某个框架的实际语法:

1
2
3
4
5
6
7
8
9
10
页面「导航栏」
容器「根节点」
内边距 = 24
子项间距 = 12

按钮「设置入口」
文字 = 「设置」
尺寸 = 180 × 48
圆角半径 = 12
动作 = 「请求打开设置页」

这里表达的是页面结构和操作意图,而不是保存一组已经创建好的窗口对象。动作可以由页面导航层解释,也可以交给业务控制器消费。

3.2 JSON 合法,不代表 UI 文档合法

一份能够解析的 JSON,仍可能包含重复 ID、不存在的父节点或循环引用。

因此,资产入口需要校验的不只是语法,还包括结构与预算:唯一根节点、ID 唯一性、父子关系无环、节点类型合法、数值有限,以及文档体积、节点数量和层级深度不能无限增长。

文字、图片和独立矢量内容通常适合作为叶节点。明确这一约束,可以避免后续代码反复判断“这个对象到底是普通容器,还是拥有内部内容系统的特殊节点”。

错误越早被发现,越容易解释。循环父子关系在资产入口处是一个文档错误,进入递归布局之后却可能变成栈溢出。

这些校验首先保证可控资产的稳定消费。若还要加载不可信内容,则需要进一步考虑解析器、路径访问、资源解码、内存与执行预算,不能把结构校验等同于完整安全沙箱。

3.3 存储顺序不能成为视觉顺序

节点可以存放在对象树中,也可以组织成实体与组件。无论采用哪种容器,都应单独维护明确的父子顺序和绘制顺序。

原因很直接:透明叠加有先后关系,而容器整理、组件迁移或实体删除,不应该改变谁盖在谁上面。

另一个容易忽略的问题是地址稳定性。如果布局库的回调保存了指向节点状态的指针,节点存储就不能在不通知它的情况下搬移对象。可以采用稳定地址存储,也可以保存带代际编号的句柄,在回调时解析;关键是不要让第三方库持有一个可能悄悄失效的地址。

四、布局:从逐个摆坐标,转向表达空间关系

4.1 布局库只解决盒子问题

Yoga 的职责是计算盒子的尺寸和位置,不负责绘制,也不负责按钮行为。它支持以 Flexbox 为主的 CSS 子集,因此适合提供熟悉的空间分配模型,但不能据此认为接入了完整浏览器布局能力。Yoga 官方说明

Flex 带来的主要变化,是作者不必为每个控件填写最终屏幕坐标,而是描述关系:横向还是纵向,谁占固定空间,谁吸收剩余空间,边距和间距是多少。

假设一个横向容器宽 620,两侧 padding 都是 32,两个子项的基础宽度分别为 180 和 220,间隔为 16。在没有额外约束的简化情况下:

1
剩余空间 = 620 - 32 × 2 - 180 - 220 - 16 = 140

如果两个子项的 grow 比例为 1:2,它们就分别增加约 46.67 和 93.33。容器尺寸变化时,空间重新分配,作者不必手动重算两个坐标。

真实布局还涉及收缩、内容测量和其他约束。这个例子的重点不是记住一条公式,而是理解:布局系统计算的是一组关系,不是一组互相独立的坐标。

4.2 逻辑尺寸、屏幕像素和安全区域

界面通常需要在不同分辨率上维持相近比例。一种简单策略是使用参考分辨率,并按较小缩放比建立逻辑视口:

1
2
3
scale = min(实际宽度 / 参考宽度, 实际高度 / 参考高度)
逻辑视口尺寸 = 实际视口尺寸 / scale
屏幕尺寸 = 逻辑布局尺寸 × scale

这是一种适配策略,不是唯一答案。也可以选择按宽度、按高度或结合显示密度进行适配,但布局、绘制、输入坐标换算必须使用一致规则。

安全区域最好成为布局输入:将可用区域表达为容器、边距或约束,而不是等控件画完之后,再统一挪动一次。后者很容易造成视觉位置已变化、命中区域却留在原处。

Flex 与绝对定位也不矛盾。内容区、按钮行和列表适合关系布局,装饰图形、徽标和覆盖层则可能更适合绝对定位。重要的是让它们经过同一套坐标变换和裁剪规则。

4.3 文字测量是布局系统提出的问题

普通容器可以根据宽高和比例求解,但文字尺寸取决于内容、字体、字号和可用宽度。布局系统必须向文字系统询问:“在这些约束下,你需要多大?”

测量接口通常需要区分三类约束:指定长度、最大长度,以及不限制该方向。Yoga 对应提供 ExactlyAtMostUndefined 三种模式;对于已经具有确定尺寸的节点,它不保证调用测量回调,最终尺寸应从布局结果读取。Yoga 外部测量接口

接口中最重要的不是函数名,而是约束含义必须一致。把“最多这么宽”错误理解成“必须这么宽”,会让很多问题看起来像文字错误,实际却是布局协议错误。

4.4 脏标记必须落实到具体阶段

按钮变色,不应该重新测量整段文字;文字改变,可能影响自身高度和后续兄弟节点的位置;滚动变化,则可能只改变变换和裁剪,而不改变内容的基础布局。

因此,可以按影响区分失效类型:

修改 主要需要重新处理的内容
字体、文本、可用宽度 文字布局,必要时重新计算容器布局
宽高、边距、父子关系 布局及受影响节点的几何
滚动、视觉偏移 变换、裁剪、命中结果
颜色、透明度 绘制描述或相应缓存

Yoga 可以在父约束不变时跳过干净节点,并通过布局变化标记帮助消费者避免无效遍历。Yoga 增量布局说明

不过,声明了这些脏标记,并不等于系统已经实现增量渲染。仍要分别检查:是否跳过了布局计算,是否跳过了结果遍历,是否重建了全部绘制项,是否重新生成了全部 GPU 几何。

初期采用完整绘制快照是合理选择。消费者每帧都能得到完整描述,失效逻辑更简单。等性能数据表明遍历或几何准备成为瓶颈,再引入子树缓存和局部更新,通常更容易控制复杂度。

五、文字:测量与绘制必须建立在同一份结果上

5.1 字符与字形不是一一对应的

逐字符查询图片、累加宽度,看似就能显示文字,却无法正确处理所有书写系统。

某些字符组合会形成连字;附加符号可能需要叠放在前一个字形上;同一个字符会随上下文呈现不同形态;字形位图的宽度也不等于排版时向前推进的距离。

HarfBuzz 负责字形整形:根据字符序列、字体及相关属性,选择字形并确定相对位置。输出不仅包括 glyph ID,还包括 offset 和 advance。HarfBuzz 原理说明

offset 决定字形相对当前位置怎么放,advance 决定后续字形从哪里继续。把它们替换成“位图宽度”,就丢失了整形结果。

一条简化的文字处理链路是:

1
2
3
4
5
6
7
字符解码与文本分析
→ 字体选择、方向与书写系统分段
→ 字形整形
→ 断行,必要时重新整形
→ 确定行位置与基线
→ 光栅化所需字形
→ 字形图集与绘制几何

这里的“简化”指流程表达,不意味着这些阶段可以忽略。例如,完整的双向文本处理需要区分逻辑顺序与显示顺序,换行也不能随意切断一个视觉上不可分割的字素簇。

5.2 不要维护两套文字尺寸算法

一种常见实现是:布局阶段用字符数估算宽度,绘制阶段再进行真正整形。短英文可能看不出问题,换成长文本、多语言或不同字体后,差异就会暴露出来。

布局认为一行放得下,绘制却换了行;布局认为高度是 20,实际字形超出边界;按钮按照估算宽度居中,标签却偏向一侧。

更稳妥的关系应该是:

1
2
测量结果 = 文字布局结果的边界尺寸
绘制位置 = 同一文字布局结果中的字形位置

布局与绘制应共享字体选择、整形、断行和基线规则,最好能够复用同一份布局结果。缓存键则需要覆盖所有影响结果的输入:文本、字体及回退配置、字号、宽度约束,以及方向、语言或样式等属性。

还要明确逻辑字号与像素字号的换算方式。即便共用一个文字服务,如果两次调用使用了不同的缩放和取整规则,仍可能在换行边界上出现差异。

5.3 图集减少资源切换,但引入了版本问题

为每个字形创建独立纹理,会产生大量资源与绑定开销。常见做法是将字形打包进图集,把绘制转换成采样同一张纹理的四边形集合。

字形缓存通常至少需要区分字体、字号和 glyph ID;如果支持不同渲染模式,还要纳入对应参数。图集也需要有容量限制和增长策略,避免偶然出现的大量字符让内存持续膨胀。

更隐蔽的问题在于更新时机:CPU 正在补充图集,GPU 可能仍在采样上一帧的版本。

一种容易推理的策略,是为更新后的图集发布新的 GPU 版本,让旧版本随在途命令保留。这样可以避免直接覆写正在被采样的纹理,代价是上传成本和短时间内的多版本内存。

也可以设计局部上传和更细粒度同步,但需要准确追踪更新区域、访问顺序与资源状态。它应当是经过测量后的优化,而不是为了节约一次分配就绕过生命周期规则。

5.4 接入整形库,不代表完成国际化排版

HarfBuzz 不负责完整的段落双向重排、断行、连字符和两端对齐。这些需要上层文字系统或其他算法配合。HarfBuzz 的职责边界

因此,产品必须明确文字能力范围。一个面向固定语言、固定字号的简单菜单,与需要聊天、编辑、选择、输入法和多语言混排的界面,不是同一种工作量。

把复杂能力拆成清楚的阶段,既有助于渐进实现,也能避免“屏幕上出现了中文,所以文字系统已经完成”这样的错误判断。

六、输入:一次操作必须有明确的归属

6.1 命中检测要与实际画面对应

输入路由通常先从视觉最上层的页面开始,再按节点绘制顺序反向查找候选对象。

但落在矩形里,不代表真的点中了控件。节点可能位于滚动区之外,也可能落在圆角被裁掉的部分。有效命中需要综合节点边界、祖先裁剪、可见性、交互状态与上层页面的阻断规则。

输入与绘制不一定要共享所有实现代码,但应该共享几何语义。否则用户看到的是一种形状,点到的却是另一种形状。

6.2 捕获解决连续操作的归属

拖动音量滑条时,手指稍微离开滑条区域,操作通常不应该立即终止;按住按钮后移出,再松开,也不应该无条件触发点击。

这要求系统记住一次输入序列属于谁,也就是指针捕获。

捕获记录可以包含指针编号、目标句柄、页面代际编号和上一次位置。基本流程可用下面的伪代码表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
当收到指针事件:
如果事件是「按下」:
目标 = 查找最上层的可交互节点
如果目标有效:为当前指针记录捕获

否则如果当前指针已有捕获:
如果事件是「取消」:
清除捕获与临时状态
结束处理,不触发动作

如果目标已失效,或页面代际编号不匹配:
取消本次操作
结束处理

如果事件是「移动」:更新目标的滑动、拖拽或滚动状态
如果事件是「松开」:
释放捕获
如果满足控件的完成条件:发出语义事件

不同控件可以有不同的完成条件。按钮通常要求松开时仍满足点击条件;滑条则可以在移动过程中持续产生数值变化。统一的是输入序列和归属规则,不是所有控件的行为。

6.3 取消必须是一等事件

窗口失焦、页面切换、设备断开、视口变化,都可能中断正在进行的操作。

如果协议只有按下和抬起,系统往往会伪造一个抬起来“收尾”。这很危险,因为抬起常常意味着确认,而系统中断并不代表用户确认。

取消应当明确表示:这次操作没有正常完成。它需要清除捕获、拖拽、键盘激活状态,以及必要的玩法保持动作,但不能产生点击。

这对触屏尤其重要。多个手指可能同时操作摇杆与按钮,不能用一个全局布尔值替代每条指针序列的生命周期。

6.4 页面名称相同,不代表是同一次展示

假设设置页产生了一个事件,事件尚未被消费,页面就被关闭并重新打开。新的设置页具有相同名称,但不应该自动接收上一次展示遗留的操作。

可以为每次完整打开分配一个递增的代际编号。输入捕获和排队事件都携带该编号,消费前检查目标是否仍存在、页面是否仍有效,以及编号是否匹配。

页面名称回答“这是哪种页面”,代际编号回答“这是哪一次展示”。节点句柄自身也需要防止删除后复用导致的身份混淆。

代际检查的价值,是把“这还是不是原来的对象”从模糊假设变成显式条件。

6.5 关闭动画结束之前,输入所有权不能提前释放

页面可以采用以下生命周期:

1
隐藏 → 打开中 → 已打开 → 关闭中 → 隐藏

打开阻断页面时,应及时建立输入阻断,而不是先显示一个画面、下一帧才开始拦截。

关闭时也要明确策略:页面可以停止响应自身控件,但在淡出结束前继续阻断下层。否则用户点击关闭按钮后,尚未消失的弹窗下面已经开始接受操作。

还要处理打开前已经存在的保持动作。例如,角色正在因摇杆而移动,弹窗挡住了后续输入,但玩法层并不知道摇杆操作应当结束。只阻止新事件,角色仍可能持续移动。

因此,模态页面的打开是一场输入所有权交接:不仅接管后续输入,还要取消此前不再有效的持续操作。

6.6 控件表达意图,业务决定执行

登录按钮应发出“请求登录”,而不是直接创建网络连接;音量滑条应报告数值变化,而不是在控件内部取得音频设备。

业务控制器再根据当前状态决定是否接受请求。按钮禁用可以改善体验,但不能成为业务防重的唯一手段。是否允许再次登录、是否正在提交、请求是否已经过期,都应由业务状态决定。

这样,UI 的反馈、输入生命周期和业务执行就能分别验证,而不必把它们压进一个点击回调。

七、渲染:优化批次,不能改变画面含义

7.1 绘制描述与 GPU 命令之间还有准备阶段

一份包含“画文字”“画图片”的快照,距离 GPU 可执行命令仍有一段距离。

文字需要展开为字形,图片需要取得可用资源,图元需要生成顶点和索引,裁剪需要组织成 GPU 数据,最终才能建立批次并录制绘制。

这意味着性能分析不能只观察页面更新。渲染准备中的文字布局、资源解码、缓存查找和几何生成,同样可能占用大量 CPU 时间。

对于较小系统,可以先将准备逻辑集中在一处;随着内容增长,再把可能阻塞的资源获取与可并行的准备任务从关键录制路径中分离。分离时仍要保持资源状态与快照版本一致。

7.2 透明绘制顺序不能随意重排

半透明红色叠在蓝色上,与蓝色叠在红色上,通常得到不同结果。因此,不能为了减少纹理切换,就把全页所有使用同一纹理的对象重新排到一起。

最容易保证正确的合批基线,是保留绘制顺序,只合并相邻且渲染状态兼容的图元。

UI 常采用预乘 Alpha:先将 RGB 乘以自身 Alpha,再进行混合。以预乘后的颜色表示,基本关系为:

1
2
C_out = C_src + C_dst × (1 - A_src)
A_out = A_src + A_dst × (1 - A_src)

交换源与目标后,颜色结果一般会变化。这就是透明对象不能任意排序的根本原因。

预乘约定还必须贯穿图片解码、颜色打包、shader 计算和混合状态。某一路径使用普通 Alpha,另一路径却按预乘方式混合,容易在透明边缘产生异常颜色。

7.3 圆角可以由覆盖率表达

绘制圆角矩形,不一定需要沿边缘增加大量三角形。另一种办法是保留一个四边形,在片元阶段计算像素到圆角边界的有符号距离。

边界内距离为负,边界外距离为正。为了减少锯齿,不直接把距离截成“画或不画”,而是在零点附近平滑改变覆盖率。屏幕空间导数可以参与估计过渡宽度,让边缘处理更贴近像素尺度。

边框也可以用内外覆盖率之差表示。由此,圆角半径和边框宽度主要成为图元参数,而不必总是导致几何拓扑变化。

7.4 裁剪不一定意味着一次状态切换

嵌套裁剪可以通过 Scissor、模板缓冲或 shader 计算等方式实现,各有适用范围。

对于矩形和圆角裁剪,一种做法是把裁剪形状放入 GPU 缓冲,让每个图元携带裁剪列表的起点与数量。片元阶段计算这些形状的交集覆盖率。

这样,相邻图元即便属于不同裁剪区域,也可能留在同一批次中,因为裁剪差异变成了数据差异,而不是每次都切换固定功能状态。

但这不是免费优化。它会增加图元参数、裁剪数据访问和片元计算。深层裁剪、大面积透明覆盖和高分辨率都可能放大成本。

批次数少,只说明提交结构更紧凑;是否更快,还要看 CPU 准备时间、GPU 用时、带宽和过度绘制。

八、GPU 生命周期:录制、提交和完成是三个时刻

8.1 函数返回,不代表 GPU 用完了数据

CPU 调用绘制函数时,通常只是在准备数据和录制命令。函数返回以后,命令可能尚未提交;提交以后,也可能尚未执行完成。

Vulkan 要求应用显式管理同步。不正确的同步会造成难查的错误,过度同步则会让 GPU 无谓等待。Vulkan 同步指南

因此,必须区分:

1
2
3
4
5
CPU 已经录制使用

命令已经提交

GPU 已经执行完成

页面对象仍然存在、绘制快照仍然存在、GPU 资源仍然安全,是三个不同命题。仅仅让快照持有一个引用计数,并不能自动保护它最终使用的所有底层资源。

8.2 让资源跟随完成凭据

一种统一做法,是让每次录制关联一个完成凭据,并由命令提交设施保留所需资源,直到对应工作终结。

需要保留的对象不仅有最终纹理,还可能包括上传暂存缓冲、顶点和索引缓冲、裁剪数据、描述符相关资源与管线对象。

同时,几何缓冲的复用要区分命令缓冲的不同录制代,以及同一录制中的多次绘制。否则,后一次上传可能覆盖前一次命令稍后才会读取的数据。

简单地在每帧末尾释放“临时对象”并不可靠;每帧等待整个设备空闲虽然容易理解,却会严重限制 CPU 与 GPU 的并行。完成凭据提供的是更精确的生命周期边界。

8.3 被安排的上传,可能从未发生

设想某张纹理刚刚录制了上传命令,CPU 随即将其标记为“已驻留”。如果这一批命令后来被取消,GPU 上并没有建立预期内容,缓存却认为工作已经完成。

下一帧只检查驻留标记,就会跳过必要上传。

因此,资源状态需要结合版本和执行结果判断。“上传已录制”可以表示一项有序依赖,但不能无条件等同于“上传已经成功”。取消或失败后,要么重新准备资源,要么让所有依赖它的消费路径明确失效。

可以把这个原则概括为:安排过一项工作,不代表它的结果已经成立。

这个原则同样适用于异步解码、缓存构建和后台资产任务。

九、矢量动画接入:局部内容自治,帧控制权统一

9.1 把复杂内容放在叶节点上

矢量动画系统通常有自己的内容结构和状态机。以 Rive 为例,artboard 可以理解为承载视觉内容的画板,状态机则根据输入控制动画行为。

集成时,一个清晰的边界是将其视为普通 UI 树中的叶节点:父级决定位置、大小、页面透明度与裁剪,矢量系统负责叶子内部的内容。

这样不必把两棵内部结构完全打通,也不会让局部动画系统重新决定整个页面的布局和输入层级。

输入桥接可以从明确的状态开始,例如按下、悬停、启用状态和进度值。持续数值、布尔状态与一次性触发器应分别处理,尤其不能把“仍然按住”解释为“每帧重新触发”。

如果产品还需要矢量内容内部的精确指针交互、事件回传或双向绑定,就应将它们作为独立协议扩展,而不是假定几个状态字段已经覆盖所有能力。

9.2 离屏合成建立稳定边界

复杂矢量绘制可能对附件类型、混合能力和资源访问方式提出额外要求。直接把这些要求传到最终显示目标,容易让局部功能影响整个渲染体系。

一种稳妥的集成方式是先绘制局部纹理,再参与普通 UI 合成:

1
2
3
4
5
6
7
8
9
10
矢量画板与状态机
│ 在宿主命令缓冲中录制

满足所需 usage 的局部纹理
│ 完成状态转换与访问同步

页面中的纹理四边形
│ 应用裁剪、透明度和叠加顺序

最终显示目标

局部纹理的 usage 必须与实际操作匹配,例如附件写入、输入附件读取、传输和采样。不能认为“它是一张纹理,所以所有用途都自动允许”。

离屏路径让复杂内容可以接入统一页面语义,但需要额外纹理、带宽、同步与合成。它应被视为一种可控的集成成本,而不是声称所有内容最终都能合成一次绘制。

9.3 第三方可以录命令,但不应悄悄接管提交

如果宿主已经管理设备、队列和帧调度,第三方适配器就应该接收当前命令缓冲和受控上下文,而不是自行创建另一套主循环。

统一提交权让帧顺序、资源回收与错误恢复有唯一入口。否则,两套系统可能各自认为资源已经完成,却无法解释彼此之间的先后关系。

这里需要明确区分“允许访问原生接口”和“转移所有权”。暴露设备句柄,是为了录制受控命令,不意味着外部库可以销毁设备、接管交换链或独立决定提交顺序。

9.4 交接资源状态,也要交接绑定状态

外部库直接录制原生命令之后,会留下两类需要处理的状态。

第一类是资源访问状态。宿主在交接前知道图像处于什么布局,第三方绘制后,必须取得它实际留下的布局与访问状态,再建立通向后续采样的同步。交接前的缓存不能代替交接后的事实。

第二类是渲染抽象层的绑定缓存。假设宿主缓存认为当前管线为 A,第三方实际绑定了 B;宿主下一次仍请求 A,于是认为“不需要重复绑定”,实际执行时就可能继续使用 B。

因此,外部录制结束后,需要在安全边界内清除相关绑定缓存,让后续绘制重新建立管线、描述符和几何缓冲绑定。动态状态等其他内容,也应按适配协议恢复或重设。

资源 barrier 解决访问顺序与布局问题,绑定缓存失效解决抽象层对命令状态的认知问题。两者不能互相替代。

9.5 无法回滚的缓存,应有明确重建策略

第三方渲染库可能在 CPU 内部记录了“某项上传已经安排”“某个布局已经变更”。如果命令被取消,宿主未必有能力逐项回滚这些私有状态。

对于无法证明仍然可信的上下文,可以采用整代重建策略:放弃当前逻辑上下文,后续重新导入和准备内容;已被在途命令引用的旧对象,继续由完成机制保护,而不是立即销毁。

局部目标纹理也只能在不再被使用时复用,并应限制在途数量,避免异常提交路径导致资源无界增长。

重建会增加成本,但恢复策略首先要回答“状态是否可信”,然后才是“怎样减少重建范围”。这比在无法解释的缓存上继续叠加修补更容易验证。

十、编辑器与业务集成:让整条内容链路真正一致

10.1 统一显示规则,保留必要的交互差异

游戏 HUD、普通页面和启动界面,可以共享节点文档、布局与绘制设施,但不必共享所有输入语义。

摇杆表达连续方向,技能按钮可能产生持续按下状态,普通按钮则表达点击完成。如果为了“统一控件”把它们都压成一个点击回调,系统反而会丢失必要信息。

更合理的目标是统一坐标、裁剪、生命周期和路由规则,让不同交互模型在这些规则之上工作。

同样,打开设置页应该是增加一个页面,而不是替换底层 HUD 文档。聊天记录、动态数值和其他运行状态,应当在页面叠加与关闭之间保持连续。

10.2 启动界面不应依赖玩法场景先存在

加载页面要显示“正在准备场景”,因此它不能反过来依赖场景中的相机或玩法实体才能运行。

可以为启动 UI 设置独立宿主,进入玩法后再挂接游戏内页面。二者共享资产协议与渲染设施,但各自管理生命周期。

这说明架构统一不等于把所有对象塞进同一个容器。应该统一的是规则,而不是消除所有宿主差异。

10.3 预览应当消费同一份文档、同一套渲染规则

如果编辑器自己画一套近似效果,运行时再画另一套,字体、圆角、换行和缩放迟早会发生偏差。

可靠的预览方式,是让编辑器使用产品运行时来生成页面画面,编辑器只额外绘制选框、辅助线和操作手柄。交互预览也应尽量进入相同输入路由。

作者工具还需要处理看似普通、实际非常关键的文档问题:改名时更新引用,删除子树时保留无关节点,撤销后恢复完整结构,保存前重新校验,检测磁盘外部修改。

保存可以采用先写临时文件、验证写入成功、再替换目标的方式,减少正式文件被写成半份内容的风险。它不等于跨进程事务;若需要多人并发编辑,还要另外设计冲突检测、合并与锁定策略。

对于预览刷新,重建整份运行时是简单而清晰的起点,但会丢失交互状态。若要保持滚动位置、焦点和动画进度,就需要稳定节点身份与文档差异更新,而不是只增加一个“热更新”按钮。

十一、依赖治理:构建稳定性也是产品能力

一个能在当前机器上显示的页面,不代表它可以在任意构建环境中稳定产生相同结果。

依赖版本、生成步骤、编译选项、符号和许可证,都属于 UI 子系统需要治理的内容。

可以将普通构建与依赖升级分开:普通构建只消费已经固定的源码和生成产物;依赖升级才执行下载、转换、shader 生成和来源核对。这样,网络波动和上游变化不会悄悄进入日常构建。

源码入库或离线依赖包会增加维护成本,但换来可审查的升级边界。来源哈希与版本记录也应区分用途:记录下载包的哈希,不等于自动证明整个展开目录从未被修改。

集成多个基础库时,还需要注意几个具体问题:

  • 两份不同版本的布局库如果导出同名符号,需要通过命名隔离或其他链接策略解决,不能依赖“碰巧链接成功”。
  • 字体相关库的可选集成可能形成双向依赖,应明确依赖方向,关闭不需要的反向链接。
  • 第三方需要的私有图形头文件,不应无意改变宿主公共接口的编译环境。
  • 不需要显示能力的构建目标,应通过依赖检查阻止 UI、图形设备等模块沿链接关系进入。

还有一个容易被忽略的层次区别:复用一份内存分配库的实现,不等于共用同一个分配器实例。两个模块即使链接同一份 VMA,也可能分别创建自己的 allocator。

如果希望统一显存预算、统计和资源压力处理,就要继续审视实例所有权,而不能在“没有重复符号”这一步就宣布资源管理已经统一。

十二、验收:证明交接规则成立,而不只是页面能显示

截图可以帮助发现视觉问题,却不足以证明输入和资源生命周期正确。越是难复现的问题,越应该写成明确的测试条件。

测试可以分为以下层次:

层次 值得验证的问题
文档与结构 序列化往返是否丢字段,重复 ID 和循环关系是否被拒绝
布局与文字 相同输入是否稳定,纯颜色变化是否避免重复测量,缩放后是否一致
输入与页面 取消是否不产生点击,重开是否丢弃旧事件,模态是否取消保持动作
绘制与像素 圆角、嵌套裁剪、透明顺序与文字位置是否正确
GPU 生命周期 多帧在途是否安全,取消上传后能否恢复,资源是否提前复用
作者工作流 撤销重做、子树编辑、冲突检测与安全保存是否保持数据完整

12.1 用可观察行为证明优化

如果声称避免了重复测量,就统计测量调用次数:连续稳定帧不应反复测量,纯颜色变化不应触发测量,而影响内容尺寸的修改应使结果失效。

如果声称支持合批,就构造一组确定兼容的相邻图元,同时检查批次数和最终像素。仅检查批次数,可能把破坏顺序的错误优化误当作成功。

12.2 让失败路径成为常规测试

可以主动构造:按下后失焦,拖动时改变视口,事件未消费前关闭并重开页面,上传命令录制后取消,第三方内容初始化失败后重新尝试。

这些场景不是边角料。它们直接检验模块对生命周期和所有权的理解是否一致。

GPU 像素回读也比“成功生成截图文件”更有判别力。可以选择圆角外、多个裁剪交集内和透明叠加区的像素,验证其颜色关系。涉及抗锯齿、字体或不同设备时,则应合理设置容差,避免将所有像素都要求为逐字节相同。

12.3 性能结论要说明测试条件

“只有一个批次”“没有重复布局”都是局部事实,不能替代端到端性能数据。

有意义的观察至少包括 CPU 更新与准备时间、文字布局命中率、几何生成量、资源上传量、GPU 用时,以及在途资源的峰值占用。还应区分冷启动和缓存稳定后的表现。

比较方案时,需要尽量保持页面内容、分辨率、设备、字体资源和测量方法一致。没有这些条件,单独给出帧率提升百分比,很难说明优化究竟来自哪里。

十三、回到一次点击:可靠性藏在交接点里

最后,把这些机制放回一个具体场景。

用户在导航栏按下设置按钮,输入系统记录指针归属;在满足点击条件的位置松开后,页面导航接收打开请求。设置页取得阻断身份,底层 HUD 的保持动作被取消。

布局系统结合文字测量结果确定几何,页面运行时将当前状态复制成有序绘制快照。渲染准备把文字展开为字形,取得纹理,生成保持叠加顺序的批次,再将上传和绘制录入宿主命令缓冲。

如果页面包含矢量动画,局部内容先进入目标纹理,交还明确的资源与绑定状态,然后按页面顺序合成。提交之后,相关纹理和缓冲区继续被保留,直到对应 GPU 工作结束。

稍后关闭设置页,淡出期间仍阻断下层输入;页面真正退出后,操作权才回到玩法。再次打开时,新的代际编号会把它与上一次展示区分开,旧事件不能跨越这条边界。

从用户角度看,这只是“点开设置,再关掉”。从工程角度看,它经历了文档、状态、输入、布局、渲染和资源生命周期的一整套协作。

自研 UI 的核心成果,正是让这种协作有确定规则:内容变化知道该使什么失效,操作中断知道该取消什么,外部后端知道该交还什么,GPU 工作结束之前知道该保留什么。

画出控件是起点。让每一次操作、每一份数据和每一个资源都有清晰的归属,才是一套 UI 子系统走向可靠的关键。

本文作者:Berg Zha

本文链接:https://junglemanpro.com/posts/b8c2564b/

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

关于

我是谁
一个热爱底层渲染的人
与我交流
bergzha@gmail.com
ESC 关闭 | 导航 | Enter 打开
输入关键词开始搜索