Skip to main content

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}