立即模式下的 Egui

发布时间 2023-11-05 17:32:55作者: 那阵东风
title: 
author: 阿东
keywords:
- Rust
- Rust Programming Language
- egui
- eframe
- Technique
description: Rust 优秀 GUI 库 egui 采用的立即模式到底有什么特点? 为什么选择了它?
author_email: zhongdong_y@outlook.com
created_at: "2023-03-31"
updated_at: "2023-03-31"
tags:
- Rust
- egui
permanent_link: egui-immediate-mode
renderer_params: 
- enable-toc

立即模式 (Immediate Mode) 和保留模式 (Retained Mode) 是两种不同逻辑的图形 API 实现方法. Windows 中 Direct2D 使用的是立即模式, 而 WPF 是用的是保留模式. Egui 本身使用的也是立即模式.

图形 API 的实现方式

立即模式

立即模式被称为过程性的 (Procedural)[1], 也就是说图形绘制命令的直接发出者是应用程序. 在立即模式下, 每一帧渲染的过程中:

  1. 应用程序 根据自身程序状态信息生成直接绘制指令
  2. 图形 API 收到指令后进行处理并将绘制指令渲染在屏幕上

图片出处 Wikipedia[2]

保留模式

保留模式被称为声明式的 (Declarative)[1:1], 意指应用程序只负责根据图元和逻辑声明一些场景和模型. 而图形库则负责保存和渲染这个场景的内容. 在保留模式下, 每一帧渲染的过程中:

  1. 应用程序 根据自身程序状态信息描述对象状态和场景内容
  2. 图形 API 根据场景内容和对象属性生成绘制指令并渲染在屏幕上

图片出处 Wikipedia[2:1]

比较

在保留模式下, 整个渲染场景中的所有对象全都由图形库进行管理. 应用程序可以改变每个对象的属性 (比如位置, 角度, 纹理等), 也可以向场景中增加或从其中删除某些对象. 但是保留模式的应用程序却无法决定什么要渲染, 什么不渲染, 这些工作是由图形库来执行的.

立即模式下则完全相反, 渲染场景是由应用程序进行管理维护的. 应用传输给图形库的就是每一帧需要渲染的内容. 因此立即模式的应用程序不仅仅要管理对象的属性, 或者增减对象, 还要根据场景的状态 (比如用户交互事件) 来决定是否要修改渲染对象的状态, 最终生成对应的绘制指令发送给图形库.

这样看来, 立即模式的图形 API 具备了极高的灵活度, 但是也相较保留模式增加了更多的复杂度. 另一方面, 立即模式由于跳过了由图形库管理场景的过程, 所以内存占用往往较小, 渲染速度也会更快.

Egui 的立即模式

Egui 完全基于立即模式进行设计[3], 所以开发者必须要在 update 函数之外存储应用程序的输入状态, 存储应用程序的交互结果, 以及定义各种标志位.

设计考量[4]

设计者在考虑使用立即模式实现 egui 时, 主要看中以下的优点:

  1. 从编码角度来看:
    1. 不需要大量的 on_click 一类的回调函数. 因为点击事件是在当前帧渲染的同时执行的, 而不是执行完毕后再修改对象状态, 等待后续帧渲染.
    2. 界面渲染函数可以非常简单, 就是一个函数, 直观而明确.
  2. 从性能和安全的角度来看:
    1. 不会出现回调函数中引用了某个资源 (如某个闭包中的变量), 但实际执行时此变量已经被修改或销毁的情况.
    2. 渲染的内容就是应用程序实时状态的准确反应, 不会出现渲染的内容已经过时消失的情况[5].
    3. 几乎所有类型的引用错误和所有权错误都可以在编译过程中发生, 杜绝运行时难以排查的内存溢出问题.

但是缺点也是显而易见的. 首先是编码难度随着灵活性显著提升, 这对于完全没有接触过立即模式的开发者来说简直是重温初学 Rust 时的痛苦. 其次是布局方面的天然缺陷, 因为立即模式的顺序逻辑不适合进行内外相互关联的布局, 比如 窗口布局网格布局[6]. 再者是 CPU 开销, 使用立即模式需要对每一帧 update 方法的执行时间进行严格的控制. 因此对于较多元素的渲染场景应该充分考虑部分渲染的方式进行优化.

实现方式

在较为常见的使用控件 (Widget) 的场景, egui 的每一个渲染帧 (每秒钟约 60 帧) 会执行这样一系列操作:

  1. Frame 结构体的 update 方法中, 对遇到的每一个 Widget trait 实例, 调用其 ui 方法, 计算出对应的布局尺寸和绘制位置.
  2. 检查是否存在用户交互事件与当前的 Widget 实例相关联, 如果有, 作相应标记.
  3. 判断完成后将对应的形状和文字 (如果有) 放入帧渲染列表中 (等待 update 方法调用结束后进行绘制).
  4. 返回 Response 对象, 供后续应用程序逻辑调用判断使用.
  5. 渲染形状和文字.

其中有一些关键的 trait 需要着重说明.

egui::Widget

所有需要对屏幕内容进行绘制的结构体都需要实现这个 trait. 同时, 签名如同 |ui: &mut egui::UI | -> egui::Response {} 的闭包也都实现了 Widget trait, 故而它们都可以用于 ui.add() 方法中.

当我们自行实现 Widget 时, 需要实现 Widget::ui 方法, 用于分配空间, 判断用户交互, 绘制并最终返回一个 Response. 值得一提的是, ui 方法会消耗当前类型的对象, 因此调用之后这个对象就失效了. 这时典型的 builder 设计模型[7]的例子, 我们所有的 Widget 都因为这一特殊的设计而不能对状态进行管理.

egui::Response

所有对 egui::Ui 添加 Widget 的操作都需要返回 Response 结构体的实例. 它保存了渲染上下文信息 (ctx), 渲染的图层信息 (layer_id), 渲染区域信息 (rect), 场景信息 (sense) 等. 并且这个实例还保存了刚才的 Widget 是否与用户产生了交互的信息, 可以通过其定义的各种方法 (如 clicked(), hovered()) 进行判断, 进而执行相应的逻辑.



  1. Retained Mode Versus Immediate Mode, Microsoft Learn ↩︎ ↩︎

  2. Retained Mode, Wikipedia ↩︎ ↩︎

  3. Understanding immediate mode, docs.rs ↩︎

  4. Why immediate mode, github.com ↩︎

  5. 这是由于保留模式下, 渲染场景负责管理和维护渲染对象. 但是当程序不再需要某个对象后, 该对象可能处于某些原因 (比如开发人员遗忘) 而继续存在于渲染场景内存中. 这种现象不仅会导致渲染错误, 还可能造成大量的内存占用和性能损失. ↩︎

  6. 这类布局的显著特征是: 内部元素的尺寸和位置依赖外部元素的尺寸和位置, 但是外部元素的尺寸和位置 (如内外都居中) 却又同时依赖内部元素的尺寸信息. 某些情况下可以通过前后帧调整来补足, 但这可能导致屏幕内容瞬闪的异常体验; 另一种途径是反复调用多次布局函数来计算合理的布局位置及尺寸, 但这样的缺点就是极高的时延和卡顿. ↩︎

  7. The builder pattern, docs.rust-lang.org ↩︎