unlit3dx/ui.rs
1//! UI panels laid out in one world and drawn in another.
2//!
3//! A `unlit3d::ui::UiPanel` can only be built where its `egui::Context` runs: the
4//! callback is handed a `&mut egui::Ui`, which belongs to the pass building it,
5//! so it cannot be carried to another thread. A UI whose panels belong to one
6//! world and whose drawing belongs to another therefore crosses the boundary as
7//! **data**: the panel's world lays the frame out and tessellates it, and the
8//! drawing world uploads and records what comes back.
9//!
10//! The handover is one way: the panel world sends a job carrying an
11//! [`unlit3d::ui::ExternalFrame`] to the world holding the
12//! [`unlit3d::ui::UiSource`], which keeps it in a slot and draws it. A settled
13//! panel sends nothing, so the frame already there keeps being drawn. What comes
14//! back is the target the drawing side draws at, published from the source into
15//! a [`Snapshot`](unlit3d::relay::Snapshot) the panel world subscribes to, so a
16//! frame is laid out at the size and density it will be drawn at.
17//!
18//! No frame is laid out before the drawing side has drawn once: until then
19//! there is no target, and a frame laid out at a guessed size would be dropped
20//! by the source rather than drawn.
21//!
22//! The two sides hold **separate** contexts. Sharing one would have both worlds
23//! writing egui's memory and its font atlas, and egui treats one `run_ui` call
24//! as one whole frame, so two worlds running it against one context would
25//! interleave two frames into one. Their texture ids both start at zero, which
26//! the drawing side handles by keying textures by origin and id: nothing
27//! renumbers them.
28
29use core::time::Duration;
30
31use crate::job::{Job, JobSender, SendError};
32use unlit_ecs::World;
33use unlit3d::frame::Frame;
34use unlit3d::input::{InputEvent, InputEvents};
35use unlit3d::relay::SnapshotReader;
36use unlit3d::source::Source;
37use unlit3d::ui::{ExternalFrame, UiSource, UiTarget, egui, lay_out, raw_input};
38
39/// A world's panel publishing: the context its panels are built in, where its
40/// frames are sent and the target it lays out against.
41///
42/// Spawn it in the world holding the panels. Nothing drives this from outside:
43/// the world ticks it, the way it ticks any other behaviour, and each tick that
44/// produced a frame sends it to the drawing world. A tick that could not change
45/// the picture — a settled panel, no input, the same target — sends nothing, so
46/// an idle UI costs nothing.
47///
48/// The context, its font atlas and its memory belong to this world and stay
49/// there: what crosses to the drawing side is the laid-out frame, which carries
50/// no borrow of any of them.
51pub struct UiPanelHost {
52 /// The context the panels in this world are built in.
53 ctx: egui::Context,
54 /// The frame time this host has been driven through, in seconds.
55 ///
56 /// egui's animations are paced by it, so a run that steps the same deltas
57 /// draws the same picture however long those steps took: an animation is
58 /// over after a number of frames rather than after an amount of wall
59 /// clock, which is what lets a snapshot of a panel be compared.
60 time: f64,
61 /// The queue of the world that draws the frames.
62 render: JobSender,
63 /// The target the drawing side last drew at.
64 screen: SnapshotReader<UiTarget>,
65 /// Which slot this world's frames go in.
66 slot: usize,
67 /// The events of the frame being laid out, reclaimed from egui once the
68 /// pass is over so the next frame reuses the allocation.
69 event_scratch: Vec<egui::Event>,
70 /// The world's events for the frame being laid out, copied out of its
71 /// buffer before anything is laid out so the allocation is reused.
72 input_scratch: Vec<InputEvent>,
73 /// The target the last frame was laid out at, or `None` before there is
74 /// one.
75 ///
76 /// The first tick always lays a frame out, and a tick whose target
77 /// differs lays one out again however settled the panel is: the drawing
78 /// side drops a frame laid out at another size, and this is what replaces
79 /// it. A frame the drawing world's queue refused leaves this behind, so
80 /// the next tick sends it again.
81 laid_out_at: Option<UiTarget>,
82 /// The moment, in this host's own [time](Self::time), at which the last
83 /// frame laid out asked to be laid out again: a frame that named a moment
84 /// rather than asking for the next possible pass.
85 repaint_at: Option<f64>,
86}
87
88impl UiPanelHost {
89 /// Publish `world`'s panels into slot `slot` of the world behind
90 /// `render`, against the target `screen` carries.
91 #[must_use]
92 pub fn new(render: JobSender, screen: SnapshotReader<UiTarget>, slot: usize) -> Self {
93 Self {
94 ctx: egui::Context::default(),
95 time: 0.0,
96 render,
97 screen,
98 slot,
99 event_scratch: Vec::new(),
100 input_scratch: Vec::new(),
101 laid_out_at: None,
102 repaint_at: None,
103 }
104 }
105
106 /// The context the panels in this world are built in, for setting its
107 /// style or fonts up before the first frame is laid out.
108 #[must_use]
109 pub fn context(&self) -> &egui::Context {
110 &self.ctx
111 }
112
113 /// Lay out this tick's frame and send it to the drawing world, if it could
114 /// differ from the one that world holds.
115 ///
116 /// The frame is laid out here rather than where it is drawn because only
117 /// this world holds the panels and their context. A tick lays one out when
118 /// something could have changed the picture: the drawing side has not been
119 /// seen yet, the target moved, input arrived, or the last frame asked to be
120 /// laid out again — either at once or once a delay it named has elapsed.
121 /// Anything else draws the same picture, so nothing is laid out and
122 /// nothing is sent.
123 ///
124 /// A frame the drawing world's queue refused is sent again on the next
125 /// tick. A frame whose queue is closed has nowhere to go and is forgotten:
126 /// the drawing world is gone.
127 ///
128 /// The events are this world's own, read from its [`InputEvents`]: a panel
129 /// sees exactly what the rest of the world sees on the same tick.
130 ///
131 /// `frame` is the frame being stepped, and its duration is what moves this
132 /// host's own clock on. egui paces its animations by that clock, so a panel
133 /// animated across a run of frames looks the same at the same frame however
134 /// long the run took: a capture compared against a stored picture does not
135 /// depend on how busy the machine was. A frame that named a moment to be
136 /// laid out again is due once the clock has reached it.
137 pub fn publish(&mut self, world: &World, frame: &Frame) {
138 // The host's clock is the world's, so it moves on whether or not there
139 // is a target to lay out against: a frame stepped before the drawing
140 // side first drew is time the panel has still lived through.
141 self.time += f64::from(frame.delta_time.max(0.0));
142 // The drawing side is the only party that knows the frame's target, so
143 // there is nothing to lay out against until it has drawn once.
144 let Some(target) = self.screen.latest().map(|target| *target) else {
145 return;
146 };
147 // Copied out before anything is laid out: a panel may read its world's
148 // input, and the copy is what keeps that read from taking the same lock
149 // twice.
150 let mut events = core::mem::take(&mut self.input_scratch);
151 events.clear();
152 if let Some(input_events) = world.root_scope().query::<&InputEvents>().iter().next() {
153 input_events.copy_events_into(&mut events);
154 }
155 let due = self
156 .repaint_at
157 .is_some_and(|deadline| self.time >= deadline);
158 if self.laid_out_at == Some(target) && !due && events.is_empty() {
159 self.input_scratch = events;
160 return;
161 }
162 let mut scratch = core::mem::take(&mut self.event_scratch);
163 scratch.clear();
164 // The events this world received are in its own types; egui wants its
165 // own, translated at the target's density so a click lands where the
166 // pointer is in logical points.
167 unlit3d::ui::convert::to_egui_events_into(
168 &events,
169 target.screen.pixels_per_point,
170 &mut scratch,
171 );
172 self.input_scratch = events;
173 // The clock this host has been driven through, which is the one egui
174 // animates against. It is the frames' own time rather than the wall
175 // clock: a panel stepped at a rate of its own animates at that rate,
176 // and two runs of the same frames draw the same picture.
177 let input = raw_input(target, Some(self.time), true, scratch);
178 let laid_out = lay_out(world, &self.ctx, input, target.screen);
179 // The pass is over, so egui no longer needs the list it was handed:
180 // taking it back is what turns the next frame's fill into a reuse.
181 self.event_scratch = self
182 .ctx
183 .input_mut(|input| core::mem::take(&mut input.raw.events));
184 // A frame that asked for nothing named no moment, and the picture is
185 // settled. One that asked for the next possible pass is answered on
186 // the next tick, whenever that is; one that named a delay is answered
187 // once this host's clock has reached it. Either way the request lives
188 // only in the pass that made it, so keeping the moment is this side's
189 // job.
190 self.repaint_at = match laid_out.repaint_delay {
191 delay if delay == Duration::MAX => None,
192 Duration::ZERO => Some(self.time),
193 // A delay the clock cannot place is one that never comes.
194 delay => {
195 let deadline = self.time + delay.as_secs_f64();
196 deadline.is_finite().then_some(deadline)
197 }
198 };
199 match self.render.send(panel_frame_job(self.slot, laid_out)) {
200 Ok(()) => self.laid_out_at = Some(target),
201 // The queue is full: nobody took the frame, and it was dropped
202 // with its texture deltas. Leaving the target unset sends it
203 // again on the next tick.
204 Err(SendError::Full) => {}
205 // The drawing world is gone; there is nothing left to draw.
206 Err(SendError::Closed) => self.laid_out_at = Some(target),
207 }
208 }
209}
210
211/// Hand `frame` to the UI source of the world this job runs in.
212///
213/// The job walks the world's frame sources and publishes into the first
214/// [`UiSource`] it finds, which draws that slot from then on. A world with no
215/// source drops the frame, and dropping it clears its texture deltas.
216pub fn panel_frame_job(slot: usize, frame: ExternalFrame) -> Job {
217 Box::new(move |world: &mut World| {
218 for source in world.root_scope().query::<&mut Source>().iter() {
219 if let Ok(ui) = source.as_mut::<UiSource>() {
220 ui.frames.publish(slot, frame);
221 return;
222 }
223 }
224 })
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use core::cell::Cell;
231 use std::rc::Rc;
232 use unlit_ecs::bundle;
233 use unlit_wgpu::ui::ScreenDescriptor;
234 use unlit3d::input::{InputWriter, Modifiers, refresh_input};
235 use unlit3d::relay::Snapshot;
236 use unlit3d::ui::UiPanel;
237
238 /// The worlds a panel is split across: the one holding it, and the one
239 /// that draws what it sends.
240 struct TestUi {
241 /// The world that draws, holding the source frames are published to.
242 render: World,
243 /// The queue a sent frame waits in until the test runs it.
244 jobs: async_channel::Receiver<Job>,
245 /// The drawing world's end of that queue.
246 sender: async_channel::Sender<Job>,
247 host: UiPanelHost,
248 /// The target the drawing side publishes at.
249 target: Snapshot<UiTarget>,
250 }
251
252 impl TestUi {
253 fn new() -> Self {
254 let test = Self::without_target();
255 test.target.publish(test_target());
256 test
257 }
258
259 /// A drawing world that has not drawn yet, so it has published no
260 /// target.
261 fn without_target() -> Self {
262 Self::with_capacity(8)
263 }
264
265 fn with_capacity(capacity: usize) -> Self {
266 let mut render = World::new();
267 render
268 .root_scope_mut()
269 .spawn(bundle![Source::new(UiSource::new())]);
270 let (sender, jobs) = async_channel::bounded(capacity);
271 let target = Snapshot::new();
272 let host = UiPanelHost::new(JobSender::new(sender.clone()), target.reader(), 0);
273 Self {
274 render,
275 jobs,
276 sender,
277 host,
278 target,
279 }
280 }
281
282 /// Lay out and send a frame, then run what was sent in the drawing
283 /// world, the way a tick there would.
284 fn publish(&mut self, world: &World) {
285 self.host.publish(world, &frame(1.0 / 60.0));
286 self.drain();
287 }
288
289 fn drain(&mut self) {
290 while let Ok(job) = self.jobs.try_recv() {
291 job(&mut self.render);
292 }
293 }
294
295 fn published_slots(&mut self) -> usize {
296 self.frames(|slots| slots.iter().filter(|slot| slot.is_some()).count())
297 }
298
299 fn frames<R>(&mut self, f: impl FnOnce(&mut [Option<ExternalFrame>]) -> R) -> R {
300 for source in self.render.root_scope().query::<&mut Source>().iter() {
301 if let Ok(ui) = source.as_mut::<UiSource>() {
302 return ui.frames.with_frames(f);
303 }
304 }
305 panic!("the drawing world holds a UI source");
306 }
307 }
308
309 /// A frame of the duration these tests step at.
310 fn frame(delta_time: f32) -> Frame {
311 Frame {
312 delta_time,
313 index: 0,
314 size: (128, 96),
315 }
316 }
317
318 /// The target the drawing side draws at in these tests.
319 fn test_target() -> UiTarget {
320 UiTarget {
321 screen: ScreenDescriptor {
322 size_in_pixels: [128, 96],
323 pixels_per_point: 1.0,
324 },
325 max_texture_side: 2048,
326 }
327 }
328
329 /// A world with a panel that counts how often it is laid out.
330 ///
331 /// The count is what tells a laid-out frame from a skipped tick: the panel
332 /// runs once per pass egui makes.
333 fn counting_world() -> (World, Rc<Cell<usize>>) {
334 let mut world = World::new();
335 let runs = Rc::new(Cell::new(0));
336 let counted = Rc::clone(&runs);
337 world
338 .root_scope_mut()
339 .spawn(bundle![UiPanel::new(move |_, _, ui: &mut egui::Ui| {
340 counted.set(counted.get() + 1);
341 ui.label("hello");
342 }),]);
343 (world, runs)
344 }
345
346 /// Publish until the panel stops asking to be laid out again.
347 fn settle(test: &mut TestUi, world: &World) {
348 for _ in 0..8 {
349 test.publish(world);
350 if test.host.repaint_at.is_none() {
351 return;
352 }
353 }
354 panic!("the panel never settles");
355 }
356
357 /// Nothing is laid out before the drawing side has drawn once: until then
358 /// there is no target, and a frame at a guessed size would be dropped.
359 #[test]
360 fn a_panel_waits_for_the_drawing_sides_target() {
361 let (world, runs) = counting_world();
362 let mut test = TestUi::without_target();
363
364 test.publish(&world);
365 assert_eq!(runs.get(), 0, "nothing was laid out");
366 assert_eq!(test.published_slots(), 0, "no frame was sent");
367
368 test.target.publish(test_target());
369 test.publish(&world);
370 assert_eq!(runs.get(), 1, "the panel ran");
371 assert_eq!(test.published_slots(), 1, "the slot holds the frame");
372 }
373
374 /// A settled panel with no input sends nothing: the drawing side keeps
375 /// drawing the frame it has.
376 #[test]
377 fn a_settled_panel_stops_sending() {
378 let (world, runs) = counting_world();
379 let mut test = TestUi::new();
380 settle(&mut test, &world);
381
382 let settled = runs.get();
383 assert!(settled > 0, "the panel ran while settling");
384 for _ in 0..4 {
385 test.publish(&world);
386 }
387 assert_eq!(runs.get(), settled, "nothing was laid out again");
388 assert_eq!(test.published_slots(), 1, "the frame is still there");
389 }
390
391 /// Input brings a settled panel back: the frame could now differ.
392 #[test]
393 fn input_sends_a_new_frame() {
394 let (mut world, runs) = counting_world();
395 let mut test = TestUi::new();
396 let writer = InputWriter::new();
397 world.root_scope_mut().spawn(bundle![writer.subscribe()]);
398 settle(&mut test, &world);
399
400 let settled = runs.get();
401 writer.push(InputEvent::ModifiersChanged(Modifiers::default()));
402 assert!(refresh_input(&world), "the world has a buffer");
403 test.publish(&world);
404
405 assert!(runs.get() > settled, "the event was laid out");
406 }
407
408 /// A target the frame was not laid out at is laid out again, however
409 /// settled the panel is: the drawing side drops a frame that no longer
410 /// matches its size, so the slot has to be filled with one that does.
411 #[test]
412 fn a_new_target_is_laid_out_at_once() {
413 let (world, runs) = counting_world();
414 let mut test = TestUi::new();
415 settle(&mut test, &world);
416
417 let settled = runs.get();
418 let resized = UiTarget {
419 screen: ScreenDescriptor {
420 size_in_pixels: [256, 192],
421 pixels_per_point: 2.0,
422 },
423 max_texture_side: 2048,
424 };
425 test.target.publish(resized);
426 test.publish(&world);
427
428 assert!(runs.get() > settled, "the new target was laid out for");
429 test.frames(|slots| {
430 let frame = slots[0].as_ref().expect("published");
431 assert_eq!(
432 frame.screen, resized.screen,
433 "the frame names the target it was laid out for"
434 );
435 });
436 }
437
438 /// A frame that asked to be laid out again after a delay is laid out once
439 /// the delay is over, even though nothing else changed.
440 #[test]
441 fn a_delayed_repaint_comes_due() {
442 let mut world = World::new();
443 let runs = Rc::new(Cell::new(0));
444 let counted = Rc::clone(&runs);
445 world
446 .root_scope_mut()
447 .spawn(bundle![UiPanel::new(move |_, _, ui: &mut egui::Ui| {
448 counted.set(counted.get() + 1);
449 ui.ctx()
450 .request_repaint_after(core::time::Duration::from_millis(50));
451 }),]);
452 let mut test = TestUi::new();
453
454 // egui answers a request for the next possible pass with a second
455 // one, so the first passes are immediate; the delay is what the
456 // passes after those report.
457 let before = test.host.time;
458 (0..8)
459 .find_map(|_| {
460 test.publish(&world);
461 test.host.repaint_at.filter(|at| *at > before)
462 })
463 .expect("a pass named a moment to be laid out again");
464
465 // Before the deadline nothing is laid out, and the moment is kept
466 // rather than dropped by the tick that skipped. The deadline is put
467 // out of reach first, so the check does not depend on how long a
468 // pass takes.
469 test.host.repaint_at = Some(test.host.time + Duration::from_secs(60).as_secs_f64());
470 let laid_out = runs.get();
471 test.publish(&world);
472 assert_eq!(runs.get(), laid_out, "the deadline has not come due");
473 assert!(test.host.repaint_at.is_some(), "the moment is still kept");
474
475 // Stand in for the ticks until the deadline elapses.
476 test.host.repaint_at = Some(test.host.time);
477 test.publish(&world);
478 assert!(runs.get() > laid_out, "the deadline brought a frame");
479 }
480
481 /// A frame's texture ids are the ids its context allocated, and the
482 /// drawing side keys textures by origin and id, so nothing moves them.
483 #[test]
484 fn texture_ids_are_not_rewritten() {
485 let (world, _runs) = counting_world();
486 let mut test = TestUi::new();
487 settle(&mut test, &world);
488
489 test.frames(|slots| {
490 let frame = slots[0].as_ref().expect("published");
491 let managed = frame
492 .primitives
493 .iter()
494 .filter_map(|primitive| match &primitive.primitive {
495 egui::epaint::Primitive::Mesh(mesh) => Some(mesh.texture_id),
496 _ => None,
497 })
498 .collect::<Vec<_>>();
499 assert!(
500 managed
501 .iter()
502 .any(|id| matches!(id, egui::TextureId::Managed(_))),
503 "a label samples the font atlas"
504 );
505 });
506 }
507
508 /// A frame the drawing world's queue refused is sent again rather than
509 /// counted as delivered: the drawing side still holds the older one.
510 #[test]
511 fn a_frame_the_queue_refuses_is_sent_again() {
512 let (world, runs) = counting_world();
513 let mut test = TestUi::with_capacity(1);
514 test.target.publish(test_target());
515 test.sender
516 .try_send(Box::new(|_: &mut World| {}))
517 .expect("the queue has room");
518
519 // The send, without the drain: the queue is still full when it is
520 // made, so the frame is refused rather than delivered.
521 test.host.publish(&world, &frame(1.0 / 60.0));
522 assert_eq!(test.jobs.len(), 1, "the placeholder is still queued");
523 assert_eq!(test.published_slots(), 0, "no frame reached the source");
524
525 test.drain();
526 let laid_out = runs.get();
527 test.publish(&world);
528 assert!(runs.get() > laid_out, "the frame was laid out again");
529 assert_eq!(test.published_slots(), 1, "the retry reached the source");
530 }
531}