ailloli_ui_render_wgpu/frame_prep.rs
1//! Frame resource preparation (single-pass compositing).
2//!
3//! [`PreparedResources::prepare`] is the **only** step that touches the GPU /
4//! atlas / icon cache before the per-frame plan is built. It walks the frame's
5//! layers, ensures every text glyph is rasterized + pinned in the atlas, and
6//! every icon is allocated in the icon cache. The resulting [`PreparedResources`]
7//! is then consumed by the **pure-CPU** [`crate::frame_plan::FrameRenderPlan::build_cpu`]
8//! to assemble vertex arenas and batches without any device/queue access.
9//!
10//! This split keeps the plan testable without a GPU and matches the single-pass compositing
11//! invariant "no `device.create_*` / `queue.write_*` between `begin_render_pass`
12//! and `drop(rpass)`".
13
14use std::collections::{HashMap, HashSet};
15use std::sync::Arc;
16
17use ailloli_ui_core::math::Scale;
18use ailloli_ui_runtime::DrawCmd;
19
20use crate::icons::{IconCache, IconKey};
21use crate::renderer::LayerPass;
22use crate::text::{Glyph, GlyphKey, TextAtlas};
23
24/// Resources pinned / allocated for the upcoming frame.
25///
26/// - `glyphs` : every glyph requested by any `DrawCmd::Text` in the frame's
27/// layers, looked up (or rasterized + uploaded) and pinned in the text atlas
28/// for the duration of the frame.
29/// - `icons` : every icon requested by any `DrawCmd::Image`, materialized in
30/// the icon cache (bind group + texture).
31///
32/// `build_cpu` later reads these maps to emit `PlannedBatch`es with the
33/// correct `TextureBindKind`.
34///
35/// # Examples
36///
37/// ```
38/// use ailloli_ui_render_wgpu::PreparedResources;
39/// let prepared = PreparedResources::default();
40/// assert!(prepared.glyphs.is_empty() && prepared.icons.is_empty());
41/// ```
42#[derive(Debug, Default)]
43pub struct PreparedResources {
44 /// Pinned glyphs keyed by face, glyph, physical size, and DPR bucket.
45 ///
46 /// Each value is `(atlas_page, glyph_metrics)`.
47 pub glyphs: HashMap<GlyphKey, (u8, Glyph)>,
48 /// Exact icon keys uploaded or found in the persistent icon cache.
49 pub icons: HashSet<IconKey>,
50}
51
52impl PreparedResources {
53 /// Walks `layers` and prepares atlas glyphs + icon cache entries.
54 ///
55 /// Touches `device`, `queue`, `atlas` and `icon_cache`. Callers must have
56 /// already called `atlas.start_frame()` (Renderer keeps this contract).
57 /// Physical glyph sizes are rounded and clamped to `8..=128`; icon sizes to
58 /// `8..=256`; the DPR bucket is `round(dpr * 100)` clamped to
59 /// `1..=u16::MAX`. Missing face blobs increment atlas diagnostics and omit
60 /// that glyph rather than panicking.
61 ///
62 /// # Examples
63 ///
64 /// ```no_run
65 /// use std::{collections::HashMap, sync::Arc};
66 /// use ailloli_ui_core::math::Scale;
67 /// use ailloli_ui_render_wgpu::{frame_prep::PreparedResources,
68 /// icons::IconCache, text::TextAtlas};
69 /// fn prepare(atlas: &mut TextAtlas, icons: &mut IconCache,
70 /// device: &wgpu::Device, queue: &wgpu::Queue,
71 /// layout: &wgpu::BindGroupLayout) -> PreparedResources {
72 /// atlas.start_frame();
73 /// let faces: HashMap<u64, Arc<[u8]>> = HashMap::new();
74 /// PreparedResources::prepare(&[], Scale::new(1.0), atlas, icons,
75 /// device, queue, layout, &faces)
76 /// }
77 /// ```
78 #[allow(clippy::too_many_arguments)]
79 pub fn prepare(
80 layers: &[LayerPass<'_>],
81 scale: Scale,
82 atlas: &mut TextAtlas,
83 icon_cache: &mut IconCache,
84 device: &wgpu::Device,
85 queue: &wgpu::Queue,
86 texture_bind_group_layout: &wgpu::BindGroupLayout,
87 face_blobs: &HashMap<u64, Arc<[u8]>>,
88 ) -> Self {
89 let mut glyphs: HashMap<GlyphKey, (u8, Glyph)> = HashMap::new();
90 let mut icons: HashSet<IconKey> = HashSet::new();
91 let scale_100 = (scale.dpr * 100.0).round().clamp(1.0, u16::MAX as f32) as u16;
92
93 for layer in layers {
94 for cmd in layer.cmds {
95 match cmd {
96 DrawCmd::Text(dt) => {
97 for gi in dt.layout.glyphs() {
98 let physical_px_size = ((gi.px_size as f32) * scale.dpr).round();
99 let key = GlyphKey {
100 face_id: gi.face_id,
101 font_index: gi.font_index,
102 px_size: physical_px_size.clamp(8.0, 128.0) as u16,
103 glyph_id: gi.glyph_id,
104 scale_100,
105 };
106 if glyphs.contains_key(&key) {
107 // Already pinned via earlier layer this frame.
108 continue;
109 }
110 let Some(blob) = face_blobs.get(&gi.face_id) else {
111 atlas.record_missing_face();
112 continue;
113 };
114 if let Some(entry) = atlas.get_or_rasterize_pinned(
115 device,
116 queue,
117 texture_bind_group_layout,
118 key,
119 blob.as_ref(),
120 ) {
121 glyphs.insert(key, entry);
122 }
123 }
124 }
125 DrawCmd::Image(img) => {
126 let physical_px_size = img.rect.w.max(img.rect.h) * scale.dpr;
127 let key = IconKey {
128 icon: img.icon.clone(),
129 px_size: physical_px_size.round().clamp(8.0, 256.0) as u16,
130 scale_100,
131 };
132 if icons.contains(&key) {
133 continue;
134 }
135 let _ = icon_cache.get_or_create(
136 device,
137 queue,
138 texture_bind_group_layout,
139 &key,
140 );
141 icons.insert(key);
142 }
143 DrawCmd::Rect(_)
144 | DrawCmd::RRect(_)
145 | DrawCmd::Border(_)
146 | DrawCmd::BoxShadow(_)
147 | DrawCmd::RingProgress(_)
148 | DrawCmd::Polyline(_) => {
149 // Solid primitives don't need GPU resources beyond the
150 // pipelines + clip bindings, which the renderer owns.
151 }
152 }
153 }
154 }
155
156 Self { glyphs, icons }
157 }
158}