Skip to main content

unlit3d_examples/scenes/
ui_only.rs

1//! The UI-only scene: egui drawn into a frame with no mesh source and no
2//! camera.
3//!
4//! Ported from the `ui_only_draws_without_a_camera` snapshot test: the frame
5//! shape that used to produce nothing, because the renderer bailed out when
6//! the world held no camera. The snapshot `ui_only.webp` freezes the second
7//! frame — the first only warms egui's font atlas up.
8
9use super::{SceneControl, SceneDef, SceneOptions, TEST_SIZE};
10use unlit3d::prelude::*;
11use unlit3d::ui::egui;
12
13/// The colour the scene clears to, distinguishable from every panel.
14const CLEAR_COLOR: wgpu::Color = wgpu::Color {
15    r: 0.02,
16    g: 0.02,
17    b: 0.02,
18    a: 1.0,
19};
20
21/// The scene: a rich egui interface and nothing else.
22pub static SCENE: SceneDef = SceneDef {
23    id: "ui_only",
24    title: "UI only",
25    description: "a rich egui panel, no mesh source and no camera",
26    size: TEST_SIZE,
27    baseline: None,
28    clear: Some(CLEAR_COLOR),
29    frames: 2,
30    step_seconds: None,
31    samples: 1,
32    depth: false,
33    ui: true,
34    reproducible_ui: false,
35    build,
36};
37
38/// Build the UI-only scene's world content.
39fn build(
40    world: &mut World,
41    _context: RenderContext,
42    _pass: Entity,
43    ui: Option<Entity>,
44    _size: (u32, u32),
45    options: SceneOptions,
46) -> SceneControl {
47    if options.ui {
48        super::spawn_in(
49            world,
50            ui.expect("the UI source was mounted"),
51            bundle![rich_panel()],
52        );
53    }
54
55    SceneControl {
56        // The interface is static: egui needs no per-frame advance. The UI
57        // source lays it out at the frame's own size, so a resize needs
58        // nothing from the scene.
59        advance: Box::new(|_world, _frame, _delta, _size| {}),
60        // The second frame is the first with the font metrics laid out.
61        snapshot: Box::new(|frame| (frame == 1).then(|| "ui_only.webp".to_owned())),
62    }
63}
64
65/// Where the rich panel puts its parts, in logical points.
66pub(crate) const WIDGETS: Rect = Rect::new(8.0, 8.0, 112.0, 128.0);
67pub(crate) const PICTURE: Rect = Rect::new(136.0, 8.0, 112.0, 72.0);
68pub(crate) const BAND: Rect = Rect::new(8.0, 136.0, 240.0, 40.0);
69
70/// A shade of [`test_image`] the scene's painted circle reuses.
71pub(crate) const LIGHT_GREEN: egui::Color32 = egui::Color32::from_rgb(96, 255, 96);
72
73/// A rich, deterministic interface: real widgets, a texture and painted
74/// geometry.
75///
76/// Everything sits at a fixed place, and nothing here reads the clock or reacts
77/// to layout feedback, so two frames of the same world come out identical —
78/// which is what lets it stand as a snapshot. The blend band is translucent so
79/// that it shows the UI blending over whatever was drawn before it rather than
80/// replacing it.
81///
82/// Shared with the mesh-and-UI scene, which draws the same panel over a cube.
83pub(crate) fn rich_panel() -> UiPanel {
84    // The texture is registered inside the closure because it needs a context,
85    // and cached so the passes egui runs in one frame share one handle. The
86    // switch and the slider keep their state between frames for the same
87    // reason: a widget that changed per frame would make the snapshot flaky.
88    let mut image: Option<egui::TextureHandle> = None;
89    let mut spin = true;
90    let mut blend = 0.5f32;
91
92    UiPanel::new(move |_world, _entity, ui| {
93        let image = image.get_or_insert_with(|| {
94            ui.ctx().load_texture(
95                "unlit3d::test-image",
96                test_image(),
97                egui::TextureOptions::NEAREST,
98            )
99        });
100
101        // The widgets go into a child ui pinned to a fixed rectangle, so their
102        // layout does not depend on how much room the other panels took.
103        ui.scope_builder(
104            egui::UiBuilder::new()
105                .max_rect(WIDGETS.egui())
106                .layout(egui::Layout::top_down(egui::Align::Min)),
107            |ui| {
108                ui.heading("unlit3d");
109                ui.label("frame source UI");
110                ui.separator();
111                ui.checkbox(&mut spin, "spin");
112                ui.add(egui::Slider::new(&mut blend, 0.0..=1.0));
113                ui.add(
114                    egui::ProgressBar::new(blend)
115                        .desired_width(104.0)
116                        .text("load"),
117                );
118            },
119        );
120
121        let painter = ui.painter();
122        // The texture, stretched over its own rectangle. `NEAREST` keeps its
123        // texels square, so a sampled pixel is one of the eight colours the
124        // image was built from.
125        painter.image(
126            image.id(),
127            PICTURE.egui(),
128            egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
129            egui::Color32::WHITE,
130        );
131        painter.line_segment(
132            [
133                egui::pos2(PICTURE.min.x, 86.0),
134                egui::pos2(PICTURE.egui().max.x, 86.0),
135            ],
136            egui::Stroke::new(1.0, egui::Color32::from_gray(120)),
137        );
138        // Shapes no widget produces, so the frame exercises the tessellated
139        // path as well as the widget one.
140        painter.circle_filled(egui::pos2(190.0, 112.0), 15.0, LIGHT_GREEN);
141        painter.circle_stroke(
142            egui::pos2(190.0, 112.0),
143            15.0,
144            egui::Stroke::new(3.0, egui::Color32::WHITE),
145        );
146        painter.rect_stroke(
147            egui::Rect::from_min_size(egui::pos2(212.0, 96.0), egui::Vec2::new(36.0, 32.0)),
148            3.0,
149            egui::Stroke::new(2.0, egui::Color32::from_rgb(255, 0, 0)),
150            egui::StrokeKind::Inside,
151        );
152        painter.rect_filled(
153            BAND.egui(),
154            0.0,
155            egui::Color32::from_rgba_premultiplied(128, 0, 0, 128),
156        );
157    })
158}
159
160/// The texels of the test image: four quadrants, each checkerboarded between a
161/// light and a dark shade.
162pub(crate) fn test_image() -> egui::ColorImage {
163    const SIZE: usize = 8;
164    const QUADRANT: usize = SIZE / 2;
165
166    fn shades(x: usize, y: usize) -> ((u8, u8, u8), (u8, u8, u8)) {
167        match (x / QUADRANT, y / QUADRANT) {
168            (0, 0) => ((255, 96, 96), (160, 0, 0)),
169            (1, 0) => ((96, 255, 96), (0, 160, 0)),
170            (0, 1) => ((96, 96, 255), (0, 0, 160)),
171            _ => ((255, 255, 96), (160, 160, 0)),
172        }
173    }
174
175    let pixels = (0..SIZE)
176        .flat_map(|y| {
177            (0..SIZE).map(move |x| {
178                let (light, dark) = shades(x, y);
179                let (r, g, b) = if (x + y) % 2 == 0 { light } else { dark };
180                egui::Color32::from_rgb(r, g, b)
181            })
182        })
183        .collect();
184    egui::ColorImage::new([SIZE, SIZE], pixels)
185}
186
187/// A rectangle in logical points.
188#[derive(Clone, Copy)]
189pub(crate) struct Rect {
190    pub(crate) min: egui::Pos2,
191    pub(crate) size: egui::Vec2,
192}
193
194impl Rect {
195    pub(crate) const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
196        Self {
197            min: egui::Pos2::new(x, y),
198            size: egui::Vec2::new(w, h),
199        }
200    }
201
202    pub(crate) fn egui(self) -> egui::Rect {
203        egui::Rect::from_min_size(self.min, self.size)
204    }
205}