目的:在动下一刀之前,先把
rjw_ui的分层、数据流、真实成本、结构欠账摊开, 让"接下来做什么"是一个有依据的决定,而不是上一轮结论的惯性延续。数据来源:
examples/eg260818UI --frames 520(稳态 120 帧滑窗均值)+ 一次 受控实验(只改一个常量)。复现命令见文末。未提交任何估算值——每一行都是实测。
不写任何选项时,窗口 / 控件必须自己看起来是对的。 应用只在想要控制时才写
.width() / .height() / .pos() / .vscroll(..) / .hscroll(..) / .resize(..)。任何"必须写某个
选项否则坏掉"的组合都是缺陷,不是"用法要求"。
由此推出的四条硬规矩(改引擎时必须守住,改坏了就是这一条被违反):
- 未指定的轴 = 由内容定(自动撑开):不写
.width()⇒ 宽 = 内容自然宽(pad + 内容 + pad); 不写.height()⇒ 高 = 内容高(vscroll(true)时再与"屏幕剩余"取 min ⇒ 矮内容不撑满、 高内容长到屏幕底再滚)。写了 ⇒ 固定;用户拖过 ⇒ 持久值接管(.width()/.height()只是初始值)。这条链是单向的:内容 → 显式 → 用户拖拽,越靠后越优先。 - 不许有"自我强化"的塌陷:任何"本帧用上一帧结果反推本帧尺寸"的解算都必须有一个
不依赖上一帧的引导值。反例(实测过两次):滚动视口在首帧取 1px ⇒ 内容按 1px 折行
⇒ 窗口 =
2×pad⇒ 下一帧照它再算 ⇒ 永远一条缝(用户:"调色板编辑器在不指定 width 的 情况下无法使用")。引导值一律取屏幕剩余,随后收敛到内容尺寸。 - 不写选项不该改变别人的行为:不调
.resize(..)/.vscroll(..)/.hscroll(..)时, 窗口与"没这个特性之前"逐像素一致(老语义由resolve_*纯函数显式表达,不靠默认值暗改)。 - 诊断先于猜测:几何争议("标题栏算不算进 scissor"、"谁把宽度撑大了")必须能用
--ui-dump/RJ_*_TRACE看到数字,而不是靠读代码推断。规则见docs/DEBUGGING.md。
横向滚动的一条附加硬规矩:
hscroll(true)(=Scroll)的那条轴不上报可用宽 (scroll_axes_avail_w)⇒ 子项保持自然宽、不折行。这是"内容真的能横着滚"的前提: 一旦上报可用宽,LimitedInParent控件就把自己压进视口 ⇒ 内容永远不溢出、横条永远不出现。 要按窗口宽折行就写hscroll(false)(默认)。
验收这类理念不能靠"看着像"——每个反例都要落成一条 sim 判据(见
docs/DEBUGGING.md§2:[无 width 也要能用]/[短内容视口]/[调色板四态]/[第二次拖柄不弹宽])。
应用(examples / 游戏 / 编辑器)
│ UiAdd(容器闭包内 &mut Ui 的方法表)/ Ui::* → add/add_at(Widget)
▼
┌─ rjw_ui ────────────────────────────────────────────────────────────────┐
│ ui.rs Ui 模块根:`Ui` 结构 + 常量 + `begin`/帧状态搬运 │
│ 733 行 / 39 字段 + 13 个 ui/ 子模块(分工见 §5.7) │
│ ui/*.rs facade / prims / interaction / scroll / panel / window /│
│ chrome / containers / builders / controls / textedit / │
│ commit / cmds —— 按职责分包,公开路径逐字不变 │
│ widgets/ Widget::ui 单方法协议 + 属性 builder(17 文件 4.1k 行) │
│ layout hit focus edit view id 纯逻辑,不依赖 Ui │
│ painter/ Painter + DrawQueue(绘制原语与播放头,3D 无关) │
│ draw.rs UiDraw 命令 + 单位换算 + Gradient/Icon/ImageBg │
│ tess.rs 圆角矩形 CPU 镶嵌(1227 行,质量与顶点数的唯一旋钮) │
│ style.rs Theme 令牌(2439 行)+ theme_toml 序列化 │
│ gpu_batch.rs 几何收集 + 切段规则 + 内容签名 │
│ backend.rs UiBackend / UiBatch / Tri │
└────────────────────────────────────────────────────────────────────────┘
│ 顶点格式 VertexP3U2C4 借自渲染器(硬依赖,见 §5.5)
▼
rjw_krusie::Render2dUiBackend(唯一的桥)→ rjw_2d_render::Render2D → wgpu
依赖方向是干净的:rjw_ui 不反向依赖 rjw_krusie;渲染器只被桥接层认识。
layout / hit / focus / edit / view / id 是纯逻辑层(可无 GPU、无 Ui 单测),
tess / gpu_batch / theme_toml 亦然——这是本项目测试密度的来源,值得保持。
① 录制 Ui::begin → 控件 push → DrawQueue.queue: Vec<UiDraw>(384 条)
记录(win, elem, depth, seq, rect, clip, kind)
② 分桶 bucket_cmds → per-win → per-depth 桶(顺序保持 = 免全量排序)
③ 派生 z0_place_for_seq → win=0 的**顶层放置序 place**(排序空间,见 §5.6)
③′ 签名 hash_cmds(全量, anchor) → 逐窗 / 逐置放内容签名(含图集世代 + 行距/字重)
④ 命中 geom.clone() ────────► cached: Vec<CachedQuad> 【拷贝 C1】864KB
未命中 collect_cmds → 镶嵌(圆角/羽化/阴影/字形)→ 写回缓存(只在变化时)
⑤ 装配 ordered.extend(cached)(move);按 (win, place, elem, g, tex, clip) 排序;
segment_runs 切段;跨条 append 【拷贝 C2】≤864KB
⑥ 交后端 flush_seg → UiBackend::submit(UiBatch) (move,无拷贝)
⑦ 桥接 Render2dUiBackend → mesh_indexed → MeshStorage.extend 【拷贝 C3】864KB
⑧ 渲染器 Render2D::build → buf_all_verts.extend_from_slice 【拷贝 C4】864KB
⑨ 上传 write_buffer(buf_all_verts) 【拷贝 C5】864KB + 191KB 索引
一帧 384 条命令 / 7 扇窗,同一份几何(23988 顶点 × 36B = 864KB + 31913 × 6B = 191KB
索引)最多被完整复制 4 次(C1–C4)再上传 1 次。
⚠ 其中 C2 是上界:单条成段走 mem::take(无拷贝)、多条段才 append,具体比例未单独
测量(asm 里还含排序与切段)。C1 / C3 / C4 是确定的整份拷贝——三处 clone() /
extend_from_slice() 都在代码里逐字可见。这就是"顶点放大"叠加"拷贝链"的结果。
⚠ 下面的绝对值是"某一次测量"的现场:同一台机器的整机速度会漂 ±30% (同一份代码不同时段测出的
finish能从 0.50ms 到 0.77ms)。跨天比较无效, 任何结论都要同机交错 A/B(ENGINE_GUIDE.md§18.21 的表就是这么测的)。
稳态(520 帧,后 3 个 120 帧滑窗一致):
[perf] frame=2.03ms ui=0.83ms (prologue=0.01 record=0.25 finish=0.57)
| ui: sort=26.7us sig=61.4us collect=2.7us clone=168.9us
submit=289.3us(asm=164.8 flush=124.5)
| render: total=2.03ms encode=829.6us submit=351.0us present=846.7us
| cmds=384 wins=7 cache_hit=14.0 cache_miss=0.0 clip_batches=11
segs=40 verts=23988 tris=31913
| 阶段 | µs/帧 | 性质 | 归属 |
|---|---|---|---|
prologue |
10 | 开场 | 帧级,常数 |
record |
250 | 应用录制 + 主题构造 | 应用侧,不是引擎账 |
sort(分桶) |
27 | O(n),免全量排序 | 引擎,已被压到极限 |
sig(签名) |
61 | 384 条命令全量哈希 ≈ 160ns/条 | 引擎,缓存的门票 |
collect(镶嵌) |
3 | 稳态几乎不镶嵌 | 缓存生效,几乎为零 |
clone |
169 | 拷贝 C1 | 引擎,纯搬运 |
submit·asm |
165 | 拷贝 C2 + 排序 + 切段 | 引擎,纯搬运 |
submit·flush |
125 | 拷贝 C3 + 40 次 builder | 桥接/渲染器 |
finish 合计 |
570 | ||
(render submit) |
351 | 拷贝 C4 + write_buffer |
渲染器,在 UI 计时之外 |
(encode) |
830 | GPU 编码 | 与顶点数正相关(见 §4) |
结论一:finish 的 570µs 里,C1+C2 = 334µs(59%)纯粹是 rjw_ui 自己的顶点搬运;
再算上 C3,459µs(81%)是"同一份数据搬三次"。缓存已经把 collect 压到 3µs,
剩下的成本不是"算"出来的,是"搬"出来的。
结论二:sig=61µs 是这套缓存机制的固定开销(每帧全量哈希)。它是必要的
(轻量摘要漏颜色位的历史 bug、图集世代、行距/字重都要求全量),但也意味着
"缓存"不是免费的——顶点数再降,签名开销不会降。
结论三:165Hz 下 present=847µs 是垂直同步等待,整帧 2.03ms 里 CPU 实做约 1.2ms,
UI 占 0.83ms。当前 UI 不是瓶颈——这一轮优化的价值全在"余量",不在帧率。
这一节是规矩,不是描述:任何搬运 / 新增控件 / 扩展公开面都必须先过 §2.5.2 的判据。 起因是"为了把某块代码搬进
widgets/而把引擎内部暴露成公开 API"——那是职责被 API 倒逼:公开面一旦为了搬运而开洞,边界就再也收不回来。
| 层 | 拥有(owns) | 禁止(must not) |
|---|---|---|
draw.rs |
绘制数据类型:UiDraw / DrawKind / Gradient / Icon / Size / Position / CornerRadius / text_cmd |
布局、状态、镶嵌 |
tess.rs |
形状 → 顶点(圆角 / 环带 / 羽化 / 阴影);纯几何 | 认识控件、读 Ui / WidgetState |
gpu_batch.rs / backend.rs |
几何收集 + 切段 + 批次契约 | 认识控件 |
painter/ |
唯一的公开绘制入口:elem / seq / clip 推进 + 原语(panel / text / rect / shadow / grip…) |
布局决策、跨帧状态 |
layout / hit / focus / edit / id / view |
纯逻辑内核(无 Ui、可单测):光标与结算 / 命中与遮挡 / 焦点链 / 文本编辑状态机 / ID / 裁剪分层 |
拿 Ui、画东西、持久状态 |
style.rs / theme_toml.rs |
主题令牌 + 序列化 | 行为 |
state.rs |
跨帧状态(UiState + 9 个模块视图,见 §5.3) |
每帧事实(那在 Ui) |
ui.rs + ui/*(Ui) |
每帧事实 + 编排:录制序、容器 / 窗口、布局责任链、命中裁决、z-order、提交、诊断(子模块分工见 §5.7) | 实现控件外观(那是 widgets/);push 原始绘制队列 / 调 next_seq |
widgets/ |
控件:Widget::ui 协议、每控件状态、外观、交互语义;只用公开面 |
读 ui.theme 字段 / 写引擎状态(ui.theme.x = ..)/ 调 UiAdd::ui_mut() / 碰 tess / gpu_batch / painter.q |
UiAdd |
容器闭包内的便捷方法 | 被控件调用(ui_mut() 只属于容器包装:Panel / Pack / Grid / Window / Scroll / FlexCtx / ViewCtx,以及本身就是容器的 MenuCtx / MenuBar) |
依赖方向(单向,无环):widgets → ui → {painter, layout, hit, focus, edit, view, id, state, style};
widgets 不得依赖 tess / gpu_batch / backend;ui 不得依赖 wgpu
(§5.5 的 VertexP3U2C4 / SpriteRect 例外保留,理由见该节)。
第三方控件能用的公开面(白名单,详见 docs/WIDGET_GUIDE.md):
Widget / Response / Sense / Expansion / SizeConstraints +
allocate / allocate_mode / allocate_at / allocate_sense* / interact / culled /
note_placed / resolved_size / child_rect + theme() / state()(9 个模块视图)/
scale() / mouse_*() / key_down*() / hit_abs / register_focus / claim_press /
set_cursor / elem_hint / painter() + push_panel_like* / push_solid_rect /
push_border_rect / push_text_rect* / icon_at / image_at / push_resize_grip(_at)。
- 纯 vs 有状态:形状→顶点、命令→批次、值→布局各自纯;出现
Ui/WidgetState即越界。 - 每帧 vs 跨帧:
Ui的字段是每帧重建的事实;UiState是跨帧持久。别把跨帧值塞进Ui(每帧重建 = 丢),也别把每帧值塞进UiState(会残留)。 - 公开面只暴露"语义",不暴露"事实":新增公开 API 必须能写出"控件作者为什么需要它"。"只有让某次搬运能编译"是唯一理由 ⇒ 拒绝——要么把该件的职责判给正确的一层,要么在正确的一层补语义化原语(例:需要"播放头" ⇒ 给
Painter补一个 push 原语,而不是公开Ui::next_seq)。 - 控件不得写引擎:要传配置就给函数参数(
&InputStyle),不许ui.theme.x = ..。 - 引擎不得画控件:
ui/*里不得 push 原始队列 / 调next_seq;要画就走Painter原语。 ui_mut()只属于容器;控件走申请 / 交互 / 绘制原语。- 搬运提交只改结构、不改行为:同一提交不夹带视觉 / 交互变更;行为改动单独提交并由
--sim-*守着(15+1 个仿真是"零行为变化"的证据)。
| # | 现象 | 证据 | 判据 | 处置 |
|---|---|---|---|---|
| 1 | 控件读 ui.theme 字段(crate 私有)而不是公开的 ui.theme() |
曾 ~36 处读 + 9 处 &ui.theme(menu.rs / colorpicker* / dropdown.rs / checkbox.rs / segmented.rs / label.rs / numberinput.rs / slider.rs / fontmodal.rs / texteditor.rs / button.rs / divider.rs) |
3 | ✅ 已修:全部改成 ui.theme()(同一事实只留一条入口;texteditor.rs 的写入属 #2) |
| 2 | 控件写引擎帧内主题把样式传给引擎里的核心 | texteditor.rs:377 mem::replace(&mut ui.theme.input, style)、:393 还原 |
4 | 核心改为收 &InputStyle 参数(D3 第一步)——尚未修,注释已指向本条 |
| 3 | 控件调 UiAdd::ui_mut() |
fontmodal.rs:148/195(要 child_rect / cursor_pos / wrap_buffer / push_*) |
6 | 记为 D4 已知缺口:UiAdd 目前不提供这些"组合控件自己排版"的原语;本轮只清理 ui.theme(#1),要把它们做成公开 compose 面是独立一轮。MenuCtx / MenuBar 的 ui_mut 是容器实现 ⇒ 白名单 |
| 4 | 控件读引擎几何事实表 | 曾 menu.rs:377 ui.state().window_rects |
2/3 | ✅ 已修:UiState::windows() 模块视图新增只读 rects()(与 debug_dump 同口径),menu.rs 改用它 |
| 5 | 引擎自己画控件外观(原始队列 + next_seq) |
ui/controls.rs::draw_check_common |
5 | 改走 Painter / 公开矩形原语(D2 第一步) |
| 6 | 文本编辑核心(~1000 行)住在引擎侧 | ui/textedit.rs::text_input_core / text_area_impl |
5 | 搬进 widgets/texteditor.rs(D3) |
| 7 | 公开绘制面不完整:Ui::push_draw 与 ellipsized 是 pub(crate) |
ui/prims.rs::push_draw / ui/prims.rs::ellipsized |
3 | 记为已知缺口:第三方"组合控件"(如 ColorPicker 那种自绘面板)目前写不出来;补公开面是独立一轮(D4),不在搬运算内 |
crates/rjw_ui/src/ui/tests.rs 的 widget_boundary_guard:对每个 widgets/*.rs 用
include_str! 断言不含禁用子串(painter.q / next_seq / ui.theme. / ui_mut() /
crate::tess / crate::gpu_batch),白名单逐条给出理由(容器实现、ColorPicker 的
push_draw 等)。它守"明显越界",失败信息直接指向本节与判据编号。
tess.rs 的圆角矩形是 CPU 镶嵌的同心轮廓扇形 + 羽化带:
顶点数 = 2 × 4(segs+1) + 1,segs ∈ {2,4,8,16,32}(由半径与 ARC_STEP_PX 决定)
⇒ 一个圆角矩形 = 25 / 41 / 73 / 137 / 265 个顶点。
只把 ARC_STEP_PX 从 2.0 改到 4.0(弧距粗一倍,段数减半,其余全不动):
| 指标 | 基线 | 粗弧距 | Δ |
|---|---|---|---|
verts |
23988 | 15192 | −37% |
tris |
31913 | 18603 | −42% |
clone |
169µs | 131µs | −22% |
submit·asm |
165µs | 128µs | −23% |
submit·flush |
125µs | 82µs | −34% |
finish |
570µs | 450µs | −21% |
encode(GPU) |
830µs | 704µs | −15% |
结论四:至少 37% 的顶点是圆角弧的采样点,而且这只是"两倍弧距"——一个圆角矩形
若走 SDF(1 个 quad = 4 顶点)理论上是 6–66 倍的差。这部分顶点还穿透整条链:
每一份都要被复制 4 次、上传 1 次、并由 GPU 逐顶点处理(encode 同步降了 15%)。
结论五:verts/cmd = 62、tris/cmd = 83。对"7 扇窗、384 条命令"的界面,这是
10–20 倍的顶点放大。这才是数量级的杠杆,而不是少一次 memcpy。
Size<T> / Position<T>(draw.rs)把"逻辑像素 / 物理像素"搬到类型里,但编译器
不区分两者——Logical 被当成物理像素用只是静默错位。所以这里有一条必须遵守的纪律
(正典在 Size 的 rustdoc;内置控件与用户自定义控件同一套):
- 解释必须显式:把带单位值变成数值(布局 / 命中 / 绘制)只能用
to_physical(scale)或match,且紧邻参数解包:let w = w.into().to_physical(scale);。 禁止let w = w.into(); … w.0(读.0时单位就丢了)。 - 构造必须指名单位:实现体内造值写
Size::Logical(..)/Size::Physical(..), 不许220.0.into()。主题值 / 跨帧持久化值 / 已乘过 DPI 的值一律Physical——Theme在Ui内部已被 DPI 预乘,控件复用主题值时必须显式声明物理像素。 - 转发是唯一例外:收
impl Into<Size<..>>/ 存Option<Size<..>>的 setter 可以Some(s.into())原样存调用者的选择(例如Button::font_size存Option<Size<f32>>, 到resolve()才to_physical)。除此之外实现体内不出现隐式糖。
From<f32> / From<Vec2>(⇒ Logical)保留:它是给调用点的源码兼容糖
(width(220.0)、radius(6.0)),不是实现者的工具。它成立的前提是上面三条被遵守——
糖只负责"调用点少写几个字",单位解释一律发生在 API 边界。
兑现方式(本轮的证据):crates/rjw_ui/src 现有 86 处 to_physical 全在边界
(ui/* 13 子模块 34 / painter/prim.rs 9 / widgets/* 21 / draw.rs 定义与单测 22);
没有任何
Size::from(..);Size<..> / Position<..> 类型的字段全部是 builder 的
Option<Size<..>>(转发例外),内部状态(UiState / UiFrameState)不存单位——
一律物理像素。新控件照 WIDGET_GUIDE.md §3 的写法写即自动合规。
widgets/(17 文件 3.7k 行,Widget::ui 单方法协议)与 ui/controls.rs +
ui/textedit.rs 的遗留
*_at 控件(button_at / slider_at / checkbox_at / radio_at / combo /
text_input_at / text_area_impl …)同时对外暴露,UiAdd 再用 ~330 行
一行转发把两边的名字对齐。同一能力有三条入口(Ui::button / Ui::button_at /
widgets::Button)。
本轮进展(B:行语义 +
RowBuilder):row里的高度约束过去是"覆盖一切" (force_h_all)——多行TextEditor被压成一行高、内容只能滚动。现在按尺寸类分开:Widget::size_class()(默认SingleLine,带默认实现 ⇒ 外部控件不破)—— 单行子项被钉到行的标准高(旧行为,文字中心线对齐不变),多行子项以它为下限、 可把行撑高;行自身的高度上下限由新的RowBuilder(min_h/max_h/height/gap/pad) 设置,在Frame::settle_size末尾夹取。menu_bar/ 窗口标题栏共用的force_h_all子项 全是单行 ⇒ 几何逐像素不变(--sim-menu/--sim-chrome守着)。 新仿真--sim-ta-resize的现场(150% DPI):编辑器 300×135 → 420×215(+120/+80)时 窗口高 292 → 372(同步 +80);行高 = 215(标准行高 39)⇒ 多行确实撑高了行;往左上 过拖后 = 210×39 = 默认下限(拖不到 0);min_h(60)行里那个.height(90)的单行 控件被钉到 90 物理(= 60 逻辑)⇒ 单行标准高生效。
搬运配方(P2a,已开始执行):把遗留核心的实现体逐块搬进
widgets/<name>.rs的impl Ui<'_>块——公开路径不变(Ui::button_at还在),但ui.rs不再持有 实现;impl Ui可以写在同 crate 任何模块,因此不需要留一行转发。 两条硬约束(本轮定的):
- 只能用
Ui的公开方法(widgets是ui的兄弟模块,私有字段 / 私有方法 不可见)。缺什么就扩展公开面并写清理由,不开pub(crate)后门;- 搬运提交里只允许出现
use/ 路径 / 位置变化,行为改动单独提交——这样 15 个 sim 的数值断言就是"零行为变化"的证据。⚠ 配额与边界由 §2.5 决定:第 1 条"扩展公开面"只允许语义化扩展(要能写出 "控件作者为什么需要它");"只有让某次搬运能编译"是唯一理由 ⇒ 拒绝——那说明该件的 职责判错了层,应该在正确的一层补原语(例:核心需要播放头 ⇒ 给
Painter补 push 原语, 而不是公开Ui::next_seq)。本轮进展(A:TextEditor 行为修复):
TextEditor::allocate_rect过去只算默认尺寸, 而绘制用的尺寸由尺寸责任链解出(resizable_text_*内部)⇒ 申请尺寸 ≠ 绘制尺寸: 拖大后窗口不跟着长、后面的控件不动、框溢出父级。修法是公开Ui::resolved_size(与既有的Ui::resize_handle配对:一个读、一个写)并让自动申请先问责任链; 同时把.resize(..)的默认下限从Vec2::ZERO改成(InputStyle::min_w, InputStyle::height)(一行文字标准高,与Theme::row_h同一套标准)⇒ 拖不到 0。本轮进展(P2a 切片 1):
button_at/button_at_styled→widgets/button.rs,combo/combo_at→widgets/dropdown.rs;为此公开Ui::note_placed("显式 rect 也要算进容器尺寸",自定义控件同样需要)。ui.rs7901 → 7805 行; 15 个 sim 全[OK](--sim-*数值断言 + smoke,零行为变化)。 剩余:slider_at_styled/slider_at_drag(需公开next_seq之类的绘制原语或在Painter上补公开 push)、checkbox_at_styled/radio_at/draw_check_common(深触painter.q原始队列)、radio_groups读写的语义化 API,最后是text_input_core+text_area_impl(~1000 行,见 §5.2)。
进展(更早):菜单栏的触发器交互并入
widgets协议(allocate_sense+Response,不再手写hit_abs+update_interact),且MenuBar现在Deref到Pack—— 与MenuCtxDeref到Window同一套模式(栏里能塞UiAdd的任何控件)。 同时Divider增加axis/vertical()(公开DividerAxis),并新增独立的主题样式组Theme::menubar: MenubarStyle(栏底 / 触发器 / 竖分割线;不再借用Theme::button——借用 会让菜单条看起来像一排按钮)。窗口外框的收起状态也支持引擎托管:.collapsible(show, None)⇒ 状态进UiState::collapsed(应用用UiState::{is_collapsed, set_collapsed, toggle_collapsed}读写)。NumberInput也泛型化 到T: SliderValue(与Slider同一套类型;SliderValue只新增带默认实现的方法 ⇒ 外部自定义实现不破),拖拽数学改f64+ 十进制格点吸附(详见NumberInput模块文档)。 几处都是加 API、没有新增"第三条入口"。
ui/textedit.rs 里 text_input_core + text_area_impl 约 1000 行(含 IME 候选框、选择、
剪贴板、换行、滚动条),是唯一没被提取的控件主体。edit.rs 已经把纯逻辑拆干净了,
但绘制与交互仍在引擎侧——这是 ui/ 变胖的最大单一贡献者。
Ui 每帧重建,字段是"帧内事实 + 一次性覆盖 + 光标意图 + 责任链"的混合体;
UiState 里 widgets / radio_groups / panel_pos / window_z / window_rects / window_origins / grid_cells / text_buffers / window_quads / z0_quads / scrolls / window_widths / window_sizes / window_heights / panel_sizes / sizes / window_fx / debug_submit / debug_clip / widget_strs / folded 共 21 张以 ID 或 z 为键的表(外加
auto_pos 这张 id→位置的自动级联表;再外加 collapsed / hit_regions 等同族集合)并存。
它们的字段注释都在解释"为什么不能用另一张表"(z vs id、帧 vs 跨帧、局部 vs 绝对)——
文档很完整,但这本身就是状态空间过大的信号。
本轮新增:
folded(区块折叠状态:绝对 ID →bool的表,服务ui.foldable(..)) 与窗口的collapsed刻意分开:键空间通常不重叠,但语义不同(窗口收起改整窗尺寸与缩放 链路,区块折叠只是"本帧不录制正文"),合成一张表会让同名窗口 / 区块互相干扰、且任一侧都 无法独立演进。它是表而不是集合:曾经的HashSet(只记"折叠")无法表达"用户已经展开", 于是open(false)(默认折叠)的区块点开后下一帧又折回去;现在表里存明确态 (false= 记住展开),Foldable在首帧把open(..)的默认态落盘 ⇒is_folded与画面 从第一帧起同口径。新增一张表时必须一并加进reset_windows()(单测reset_clears_every_module_after_dirtying会当场抓出漏清)。
进展(C:模块化 + pub 化):
UiState的 42 个字段现在按关注点分成 9 个模块, 以公开只读视图的形式对外(state.frame() / widgets() / windows() / texts() / scrolls() / popups() / hits() / caches(),外加stats());写走语义化方法 (sizes_mut()/color_picker_mut()/set_menu_open()/set_combo_open()/close_popups()),不把内部表暴露成pub字段。为什么用视图而不是把存储拆成 9 个子结构:字段被引擎侧 ~200 处直接访问,拆存储会 牵动每一个调用点,而应用真正需要的是稳定的公开面——视图把公开面与内部布局解耦 (以后内部怎么挪都不破坏应用),同时模块边界就是文档边界。
附带一条真正的安全性修复:
reset()改为逐个转调reset_frame/widgets/windows/ texts/scrolls/popups/hits/caches,于是"某模块新增字段却忘了清"不可能再发生—— 新单测reset_clears_every_module_after_dirtying(脏化 8 个模块 →reset()→ 逐个 模块断言为空)当场抓出旧reset()漏清widget_strs(以及漏清debug_submit/debug_clip/window_widths/window_heights/ scratch 缓冲),已一并修掉。 行为零变化:16 个--sim-*全绿。
容器闭包需要 &mut Ui,所以 Panel / Pack / Grid / Window / Scroll / FlexCtx 各自
impl UiAdd,方法体一律是 self.ui_mut().xxx(..)。抽象只省了调用点的 p. 前缀,
没有收敛任何行为;代价是每个新 API 要写两遍(inherent + trait)。
UiBackend / UiBatch 承诺"UI 只产出批次、不调渲染器",但 UiBatch::vertices 的类型
VertexP3U2C4 直接来自 rjw_2d_render,draw.rs 也直接用 SpriteRect、
debug_draw::thick_line_quad。所以**"UI 不依赖渲染器"只在调用方向上成立**,
在数据格式上是耦合的——这正是 §6 里 L1 方案的门槛所在。
提交序原为 (win, elem, group, tex, clip),而 elem = 0 的语义是"画在本容器元素
之下"——只有"每个容器一个排序空间"才成立。窗口天然满足(一扇窗 = 一个 win 桶),
但所有非窗口内容共享 win = 0:panel_impl 的投影/底色与 scroll_at 的滚动条
(全仓仅这两处 win=0 装饰用 elem = 0)于是落进整个 win=0 的最底,被任何别的
win=0 内容穿透 ⇒ 用户看到的"可拖动面板闪烁 / ScrollBar 闪烁"。
修法是在 win 之后插入顶层放置序 place(z0_place_for_seq),排序键变为
(win, place, elem, group, tex, clip)。这条改造同时是 §6.2 的前提:只有当"一个放置
= 一个有序单元"时,合并结果才能作为单元整体缓存。
教训(写进 DEBUGGING.md §8.3):排序键少一维时,症状是"闪烁"而不是"顺序乱",
而且只有内容在动的地方才看得见——所以先看引擎自己的提交序(RJ_ORDER_TRACE),
不要靠截图猜。
ui.rs 曾 8871 行(全仓最大文件),现拆成模块根 + 15 个子模块;公开路径
(rjw_ui::Ui / crate::ui::UiAdd / ui::WindowBuilder …)逐字不变:
| 子模块 | 行数 | owns |
|---|---|---|
ui/facade.rs |
511 | 状态 / 主题访问、帧级事实搬运、光标意图、焦点注册、诊断(debug_dump / window_order / window_under_mouse) |
ui/prims.rs |
786 | 绘制原语(调试图元 / 圆角 / 渐变 / 图标 / 背景图 / panel / shadow / grip)、文本度量与排版缓冲缓存、label_at / label_wrap_at、From<Align> |
ui/interaction.rs |
625 | 命中(hit_abs 家族)、申请(allocate*)、interact、view_at、容器原语、avail_w、divider_at |
ui/scroll.rs |
632 | ScrollView(scroll_at / scroll_axes_at / list_at)、滚动条 + 滚动几何纯函数 |
ui/panel.rs |
308 | 面板(panel_at / drag_panel_at / panel_impl)、位置 / 尺寸责任链、with_id / id_for |
ui/window.rs |
1106 | window_impl / menu_bar / modal_impl + 窗口几何纯函数(溢出策略 / 裁剪 / 限位) |
ui/chrome.rs |
244 | 标题栏(条高 / 布局解算 / 绘制 / RJ_CHROME_TRACE) |
ui/containers.rs |
750 | UiAdd trait + 容器类型 + 诊断快照 + 容器入口(pack_at / flex_at / grid_at / min_size / max_size) |
ui/builders.rs |
459 | WindowBuilder / PanelBuilder / ModalBuilder / RowBuilder / WindowChrome + builder 入口 |
ui/controls.rs |
565 | 遗留 *_at 控件(滑块 / 勾选 / 单选 / 勾选绘制 / 缩放柄 / 文本框包装) |
ui/textedit.rs |
1037 | 文本编辑核心(单行 + 多行:选择 / 剪贴板 / IME / 换行 / 滚动条) |
ui/commit.rs |
876 | finish / end_frame / 提交单元与计划缓存 / 批次冲刷 / Debug 叠加 / 光标定夺与复位 |
ui/cmds.rs |
673 | 命令 → 几何(镶嵌 / 内容签名 / 字形四边形)+ 拖拽激活与键盘帧末 |
ui/foldable.rs |
516 | 可收缩区块(Foldable / FoldState / Ui::foldable / Ui::foldable_custom)+ 标题行几何与翻转判据纯函数(fold_state / fold_should_toggle / fold_header_w / fold_icon_rect / fold_label_rect / fold_guide_rect)+ 标题=标准容器(自定义标题在"固定宽 / 高度自然"的装饰容器里跑,Ui::ornament_entry_natural_h 返回内容高、标题行据此加高)+ 正文缩进与引导线 / 渐隐 + RJ_FOLD_TRACE 诊断 |
ui/namespace.rs |
105 | ID 命名空间区块(Namespace / Ui::namespace)+ 结算纯函数(namespace_size) |
ui.rs(根) |
739 | 常量、UiInit、Ui 结构、begin 与帧状态搬运、子模块声明与重导出 |
本轮定的三条规矩:
- 子模块私有 + 根重导出:
mod全部私有,公开类型经ui.rs的pub use/pub(crate) use重导出(UiAdd/WindowBuilder/Panel/WindowChrome…) ⇒crate::ui::X与rjw_ui::X两条路径都不变,lib.rs的pub use无需改动。 - 可见性只用
pub(super):impl Ui可以写在同 crate 任何模块(§5.1 的搬运配方), 子模块是crate::ui的后代 ⇒ 仍直接读写Ui的私有字段;真正需要跨子模块共享的 只有 42 处fn/const(外加 25 处需跨文件构造的结构体字段),它们提到pub(super)(=pub(in crate::ui)),不开pub(crate)后门,公开面一个 字节没变。收敛方式:搬运时先统一提升,再全部降为私有、按编译器报错逐项提升—— 两轮收敛到 0 错误(cargo check --all-targets)。 - 搬运提交只改结构:拆分前后
fn定义 284 = 284、类型 / 常量 36 = 36 (按名字与重数逐项相同,脚本核对);覆盖校验 = 8871 行中 8870 行按行搬入子模块, 唯一"丢弃"的是impl Ui<'_> {那一行(由包装代替);cargo test -p rjw_ui与 拆分前基线同为 352 通过 / 3 失败(那 3 个style.rs失败在拆分前就存在, 与本次无关),cargo clippy --workspace --all-targets零警告;22 个--sim-*全[OK](含--sim-clip/--sim-cover/--sim-dropdown/--sim-menu/--sim-resize/--sim-tuner/--sim-weight-modal的数值断言)。
本轮新增控件(
LabelEx):crates/rjw_ui/src/widgets/label_ex.rs—— 高度自定义标签 (整组TextStyle+ 字段级覆盖 + 首末两色渐变)。它只走公开面(判据:widget_boundary_guard的FILES已补label_ex.rs):Ui::cache_buffer_styled/Ui::text_size_styled(本轮由pub(crate)提为pub的"控件作者公开面",与text_size/wrap_buffer同级)、Ui::push_text_rect_ramp、crate::edit::ellipsize; 不读ui.theme.字段、不碰 painter 队列 /tess/gpu_batch。支撑改动落在引擎侧: 绘制命令DrawKind::Text增ramp: Option<TextRamp>(TextRamp= 首末两色 + 轴 + 域Glyph/Line/Frame;逐字形四角取顶点色,域与采样点都在文本视觉原点系、 采样用未裁剪字形几何 ⇒ 裁剪不改变颜色;⇒cmd_sig_hash必须一起哈希)、 排版缓存键由五元组提为state::TextKey(有名字段:字号 / 行高 / 行距 / 字体族 / 字重 / 斜体 / 拉伸 / 字距 / 换行宽 / 版本;颜色与对齐不进——它们属绘制期)。改键的动机与 §5.6 的教训同源:少一个字段 = 改了样式不刷新。
| 候选 | 实测收益 | 不做的理由 |
|---|---|---|
| 去掉缓存克隆(借用重构) | clone 169µs |
见 6.2——换个做法能顺带把它和 asm 一起吃掉,单独的借用重构是走弯路 |
切分 z0 group 0 缓存槽 |
0(稳态 cache_miss=0.0) |
上一轮残留的 0.4 次/帧在长窗口下已消失,问题不存在 |
| ⑥ GPU 持久缓冲 | — | 已按既定范围移除 |
微优化(单组 move / scratch 复用 / sort_unstable) |
落在噪声内 | 已做完并如实记为"无可宣称收益",不再重复 |
前置(阶段 8):
place(顶层放置序)作为排序维度。没有它,一个 win=0 放置 的几何就无法用单元级 key 表达序——elem在 win=0 里跨放置交错, 合并成"计划"之后序就丢了。见 §5.6。
改之前的缓存粒度是 (win, place, elem, group, tex, clip) → Geom,于是每帧都要重新合并一次:
命中 → 逐条克隆(C1)→ 组装 ordered → 排序 → segment_runs → 跨条 append(C2)→ 再交后端。
但合并结果只取决于帧内稳定的排序键 (place, elem, group, tex, clip) 与 MAX_UI_SEG_VERTS,
而段永远不跨 (win, place) ⇒ 合并结果本身可以缓存:
window_quads[id] : (sig, Vec<BatchPlan>) // 窗口 = 一个 place
z0_quads[(segment, place)] : (sig, Vec<BatchPlan>) // win=0 的一个放置
BatchPlan { texture, clip, geom: Geom, elements: Vec<u32> }
命中路径变成:HashMap::remove(&key)(move 一个 Vec 指针,O(1)、零顶点拷贝)→
按 (win, place) 排一遍单元(~15 个)→ 逐个 flush_seg(&plan) → 再 insert 回去。于是:
- C1(
clone,实测 169–194µs)消失; - C2 的
append与ordered/排序/切段消失(只在 miss 时做一次); sort(分桶)与sig保留(那是索引,不是几何);- 上一轮担心的"零拷贝两阶段读取导致整窗不提交闪烁"不再适用:数据是
remove出来的所有权值,z→id 映射只解一次,不存在"按旧 z 读新表"。
代价:UiBatch 的顶点/索引必须改借用(&[VertexP3U2C4] / &[Tri]),否则命中路径
又要在后端边界复制一份;segs(批次候选数)会小幅上升 ⇒ 真实 draw call 同步上升
(实测 40 → 44,UiBatch 数 = UI 层 draw_op_count())。⚠ 一度以为 Render2D 会把相邻
同状态批次合回去——实测不会:每个 mesh_indexed(..) 带自己的矩阵下标(Mesh { mat_idx }),
而动态段合批条件含 dyn_seg_mat ⇒ UI 批次之间永不合并(draw_ops == segs 坐实)。
这是"用 +10% 的 draw call 换掉每帧两次全量顶点 memcpy"的自觉取舍。
实测(同机交错 A/B,3 轮 × 480 帧,取中位数):
| 指标 | 改前(HEAD) | 改后 | 说明 |
|---|---|---|---|
finish |
0.68ms | 0.36ms(−47%) | |
clone |
193.6µs | 0.0µs | C1 消失 |
asm |
169.6µs | 22.8µs | C2 的排序/切段/append 消失 |
submit |
345.1µs | 244.2µs | = asm + flush(flush 因 44 次批次略涨) |
segs / draw_ops |
40 / 40 | 44 / 44 | +4 draw call(+10%) |
verts / tris |
23936 / 31887 | 23936 / 31887 | 逐位不变 |
渲染结果逐像素不变(同一套几何、同一套切段规则、同一套 scissor),16 个 --sim-*
全部通过。风险集中在缓存生命周期——这次的"零拷贝"是所有权值(remove → 提交 →
insert),不是"两阶段按 z 读同一张表",所以历史上那类"整窗不提交"的竞态结构上不存在。
§4 证明顶点量级才是数量级杠杆(37%+ 的顶点是弧采样)。但:
- 需要渲染器提供圆角 + 抗锯齿(新着色器或新顶点/实例格式)——这违反
tess.rs开头写明的"零着色器改动"不变量,属于跨 crate 的路线变更; - 圆角 + 四角渐变 + 四角各自半径 + 环带边框 + 软阴影都要重新实现;
VertexP3U2C4的pos[2]空着、uv已被白纹理/字形占用,现有顶点格式装不下 (圆心/半宽/圆角至少还要 3 个分量)。
所以本轮只把它记为唯一的方向性选项,等 6.2 落地后再评估。
- 把
ui/textedit.rs(text_input_core/text_area_impl,~1000 行)搬进widgets/textinput.rs——收益最直接(ui/掉约 12%),风险低(纯搬家 + 保持Ui::text_input_at为薄包装)。 - 收敛控件 API:遗留
*_at保留为薄包装,但把"实现"统一到widgets/, 让UiAdd只剩一层。 Ui字段分块:把"帧内事实 / 一次性覆盖 / 光标意图"拆成内嵌 struct, 39 字段降到可读量级(纯重构,不改行为)。
$env:CARGO_TARGET_DIR="C:\rust-targets\"
# 稳态基线(120 帧滑窗,取后 3 个)
cargo run -p eg260818UI --offline -- --frames 520
# 顶点归因实验:crates/rjw_ui/src/tess.rs 的 ARC_STEP_PX 2.0 → 4.0(测完务必改回)
# 门禁
cargo test --workspace
cargo clippy --workspace --all-targets
cargo run -p eg260818UI --offline -- --frames 30 --sim-clip[perf] 里 submit=…(asm=… flush=…) 是本次为做这份分析新加的两笔账
(UiStats::submit_asm_us / submit_flush_us):前者是 rjw_ui 自己的顶点装配,
后者是 flush_seg → UiBackend::submit。不分这两笔,就无法判断那 0.3ms 该算谁——
上一轮把整块 submit 当成"一次拷贝"就是这么来的。