Skip to main content

egui_elements/widgets/particles/light/
effect.rs

1//! Radial glow motes and embers for egui.
2
3use egui::epaint::Mesh;
4use egui::{Color32, Context, Id, LayerId, Order, Pos2, Rect, Vec2};
5#[cfg(test)]
6use egui::{TextureId, vec2};
7use std::ops::RangeInclusive;
8
9use super::super::shared::{ParticleLayer, ParticleRng, ordered, resolve_target};
10use super::settings::LightSettings;
11use crate::glow::{glow_mesh, size_attenuation};
12
13/// Peak wander speed of a drifting mote in pixels per second.
14const DRIFT_SPEED: f32 = 20.0;
15/// How quickly a drifting mote's velocity chases its wander target, per second.
16const DRIFT_RESPONSE: f32 = 6.0;
17/// Horizontal sway of a rising ember in pixels per second.
18const RISE_SWAY: f32 = 18.0;
19/// Upward speed of a rising ember in pixels per second.
20const RISE_SPEED: f32 = 120.0;
21
22/// Per-frame state of the [`Light`] effect, kept in egui's temporary data storage.
23#[derive(Clone)]
24struct LightState {
25   motes: Vec<LightMote>,
26   rng: ParticleRng,
27}
28
29impl LightState {
30   fn new(seed: u64) -> Self {
31      Self {
32         motes: Vec::new(),
33         rng: ParticleRng::new(seed),
34      }
35   }
36}
37
38/// One glowing mote.
39#[derive(Clone, Copy)]
40struct LightMote {
41   /// Normalized coordinates (0.0..1.0).
42   pos: Pos2,
43   /// Velocity in pixels per second.
44   vel: Vec2,
45   /// Base radius in pixels.
46   radius: f32,
47   /// Per-mote intensity in 0..=1, already scaled by the settings intensity.
48   intensity: f32,
49   /// Phase driving flicker and motion, unique per mote.
50   phase: f32,
51   /// Flicker angular rate in radians per second.
52   flicker_rate: f32,
53   /// Flicker depth in 0..=1.
54   flicker_depth: f32,
55   /// Age in seconds.
56   age: f32,
57   /// Total lifetime in seconds.
58   lifetime: f32,
59}
60
61impl LightMote {
62   fn spawn(
63      rng: &mut ParticleRng,
64      motion: LightMotion,
65      radius_range: &RangeInclusive<f32>,
66      lifetime_range: &RangeInclusive<f32>,
67      intensity: f32,
68      flicker: f32,
69   ) -> Self {
70      let pos = match motion {
71         LightMotion::Drift => Pos2::new(rng.range(0.0, 1.0), rng.range(0.0, 1.0)),
72         LightMotion::Rise => Pos2::new(rng.range(0.0, 1.0), 1.0),
73      };
74
75      Self {
76         pos,
77         vel: Vec2::ZERO,
78         radius: rng.range(*radius_range.start(), *radius_range.end()),
79         intensity: (intensity * rng.range(0.6, 1.0)).clamp(0.0, 1.0),
80         phase: rng.range(0.0, std::f32::consts::TAU),
81         flicker_rate: rng.range(1.5, 4.0),
82         flicker_depth: (flicker * rng.range(0.5, 1.0)).clamp(0.0, 1.0),
83         age: 0.0,
84         lifetime: rng.range(*lifetime_range.start(), *lifetime_range.end()),
85      }
86   }
87
88   /// Advances the mote by `dt` seconds inside `area` (never leaving it).
89   ///
90   /// Returns `true` when a rising ember climbed out of the top edge and must
91   /// be recycled; drifting motes always bounce and return `false`.
92   fn advance(&mut self, motion: LightMotion, wind: Vec2, time: f32, dt: f32, area: Rect) -> bool {
93      match motion {
94         LightMotion::Drift => {
95            // Damped wander: the velocity smoothly chases a slow phase-driven target.
96            let target = Vec2::new(
97               (self.phase + time * 0.7).sin(),
98               (self.phase * 1.3 + time * 0.5).cos(),
99            ) * DRIFT_SPEED
100               + wind;
101            let blend = (DRIFT_RESPONSE * dt).min(1.0);
102            self.vel += (target - self.vel) * blend;
103         }
104         LightMotion::Rise => {
105            self.vel.x = (self.phase + time).sin() * RISE_SWAY + wind.x;
106            self.vel.y = -RISE_SPEED + wind.y;
107         }
108      }
109
110      self.pos.x += self.vel.x * dt / area.width();
111      self.pos.y += self.vel.y * dt / area.height();
112
113      // A rising ember that crossed the top edge is recycled by the caller.
114      let escaped = matches!(motion, LightMotion::Rise) && self.pos.y < 0.0;
115
116      if matches!(motion, LightMotion::Drift) {
117         // Bounce off the area edges so drifting motes stay on screen.
118         if self.pos.x < 0.0 {
119            self.pos.x = -self.pos.x;
120            self.vel.x = self.vel.x.abs();
121         } else if self.pos.x > 1.0 {
122            self.pos.x = 2.0 - self.pos.x;
123            self.vel.x = -self.vel.x.abs();
124         }
125         if self.pos.y < 0.0 {
126            self.pos.y = -self.pos.y;
127            self.vel.y = self.vel.y.abs();
128         } else if self.pos.y > 1.0 {
129            self.pos.y = 2.0 - self.pos.y;
130            self.vel.y = -self.vel.y.abs();
131         }
132      }
133
134      // Clamp away any residual overshoot (e.g. after a spike in `dt`).
135      self.pos.x = self.pos.x.clamp(0.0, 1.0);
136      self.pos.y = self.pos.y.clamp(0.0, 1.0);
137
138      escaped
139   }
140}
141
142/// Motion model for the [`Light`] effect.
143#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum LightMotion {
146   /// Slow wandering motes that bounce off the area edges.
147   Drift,
148   /// Embers that rise from the bottom edge and sway as they climb.
149   Rise,
150}
151
152impl LightMotion {
153   /// Human-readable name, matching the settings window labels.
154   pub fn to_str(&self) -> &str {
155      match self {
156         LightMotion::Drift => "Drift",
157         LightMotion::Rise => "Rise",
158      }
159   }
160}
161
162/// A configurable radial-glow effect for egui.
163///
164/// `Light` paints soft, flickering motes — drifting haze or rising embers — on
165/// an egui layer. It maintains its state between frames using egui's temporary
166/// data storage and never disturbs the host layout.
167///
168/// All motes are appended into a single triangle mesh per frame, so stacking
169/// order is preserved and the effect costs one shape.
170#[derive(Debug)]
171pub struct Light {
172   id: Id,
173   color: Color32,
174   density: usize,
175   layer_order: Order,
176   custom_layer: Option<LayerId>,
177   custom_area: Option<Rect>,
178
179   radius_range: RangeInclusive<f32>,
180   intensity: f32,
181   motion: LightMotion,
182   wind: Vec2,
183   flicker: f32,
184   lifetime_range: RangeInclusive<f32>,
185}
186
187impl Light {
188   /// Creates a new light effect with a unique ID.
189   pub fn new(id_source: impl std::hash::Hash + std::fmt::Debug) -> Self {
190      Self {
191         id: Id::new(id_source),
192         color: Color32::from_rgb(255, 224, 178),
193         density: 48,
194         layer_order: Order::Foreground,
195         custom_layer: None,
196         custom_area: None,
197         // Neutral defaults: low density, wide soft radii, gentle flicker.
198         radius_range: 8.0..=36.0,
199         intensity: 0.7,
200         motion: LightMotion::Drift,
201         wind: Vec2::ZERO,
202         flicker: 0.25,
203         lifetime_range: 4.0..=9.0,
204      }
205   }
206
207   /// Builds a light effect from [`LightSettings`].
208   ///
209   /// Inverted ranges are normalized (swapped) so a host can never hand the
210   /// spawner an empty range.
211   pub fn from_settings(
212      id_source: impl std::hash::Hash + std::fmt::Debug,
213      settings: &LightSettings,
214   ) -> Self {
215      let (radius_min, radius_max) = ordered(settings.radius_min, settings.radius_max);
216      let (lifetime_min, lifetime_max) = ordered(settings.lifetime_min, settings.lifetime_max);
217      let light = Self::new(id_source)
218         .color(settings.color)
219         .density(settings.density)
220         .radius(radius_min..=radius_max)
221         .intensity(settings.intensity)
222         .motion(settings.motion)
223         .wind(settings.wind)
224         .flicker(settings.flicker)
225         .lifetime(lifetime_min..=lifetime_max);
226
227      match settings.layer {
228         ParticleLayer::Background => light.on_background(),
229         ParticleLayer::Foreground => light.on_foreground(),
230         ParticleLayer::Top => light.on_top(),
231      }
232   }
233
234   /// Sets the mote color.
235   pub fn color(mut self, color: Color32) -> Self {
236      self.color = color;
237      self
238   }
239
240   /// Sets the number of motes.
241   pub fn density(mut self, density: usize) -> Self {
242      self.density = density;
243      self
244   }
245
246   /// Sets the mote radius range (min..=max) in pixels.
247   pub fn radius(mut self, range: RangeInclusive<f32>) -> Self {
248      self.radius_range = range;
249      self
250   }
251
252   /// Sets the global intensity multiplier in `0.0..=1.0`.
253   pub fn intensity(mut self, intensity: f32) -> Self {
254      self.intensity = intensity.clamp(0.0, 1.0);
255      self
256   }
257
258   /// Sets the motion model.
259   pub fn motion(mut self, motion: LightMotion) -> Self {
260      self.motion = motion;
261      self
262   }
263
264   /// Sets the wind vector.
265   pub fn wind(mut self, wind: impl Into<Vec2>) -> Self {
266      self.wind = wind.into();
267      self
268   }
269
270   /// Sets the flicker depth in `0.0..=1.0` (`0` disables flicker).
271   pub fn flicker(mut self, flicker: f32) -> Self {
272      self.flicker = flicker.clamp(0.0, 1.0);
273      self
274   }
275
276   /// Sets the mote lifetime range (min..=max) in seconds.
277   pub fn lifetime(mut self, range: RangeInclusive<f32>) -> Self {
278      self.lifetime_range = range;
279      self
280   }
281
282   /// Places the light on the Debug layer (on top of everything).
283   pub fn on_top(mut self) -> Self {
284      self.layer_order = Order::Debug;
285      self
286   }
287
288   /// Places the light on the Foreground layer.
289   pub fn on_foreground(mut self) -> Self {
290      self.layer_order = Order::Foreground;
291      self
292   }
293
294   /// Places the light on the Background layer.
295   pub fn on_background(mut self) -> Self {
296      self.layer_order = Order::Background;
297      self
298   }
299
300   /// Provides full control over the rendering layer.
301   ///
302   /// This overrides `on_top`, `on_foreground`, and `on_background`.
303   pub fn layer(mut self, layer_id: LayerId) -> Self {
304      self.custom_layer = Some(layer_id);
305      self
306   }
307
308   /// Restricts the light effect to a specific area.
309   ///
310   /// If not set, it defaults to `ctx.content_rect()`.
311   pub fn area(mut self, area: Rect) -> Self {
312      self.custom_area = Some(area);
313      self
314   }
315
316   /// Updates and renders the light effect.
317   ///
318   /// This should be called every frame. It automatically requests a repaint.
319   pub fn show(self, ctx: &Context) {
320      let Some((layer_id, area)) = resolve_target(
321         ctx,
322         self.id,
323         self.layer_order,
324         self.custom_layer,
325         self.custom_area,
326      ) else {
327         return;
328      };
329
330      let dt = ctx.input(|i| i.stable_dt).min(0.1);
331      let time = ctx.input(|i| i.time);
332      let seed = self.id.value() ^ time.to_bits();
333
334      let (mut motes, mut rng) = ctx.data_mut(|data| {
335         let state = data.get_temp_mut_or_insert_with(self.id, || LightState::new(seed));
336         (std::mem::take(&mut state.motes), state.rng)
337      });
338
339      // Spawn logic
340      if motes.len() < self.density {
341         let needed = self.density - motes.len();
342         for _ in 0..needed {
343            motes.push(LightMote::spawn(
344               &mut rng,
345               self.motion,
346               &self.radius_range,
347               &self.lifetime_range,
348               self.intensity,
349               self.flicker,
350            ));
351         }
352      } else if motes.len() > self.density {
353         motes.truncate(self.density);
354      }
355
356      let painter = ctx.layer_painter(layer_id);
357      let mut mesh = Mesh::default();
358
359      for mote in &mut motes {
360         // --- Update ---
361         mote.age += dt;
362         let escaped = mote.advance(self.motion, self.wind, time as f32, dt, area);
363         // Recycle expired motes, and rising embers that climbed out of the top
364         // edge, so no glow is painted outside the effect area.
365         if mote.age >= mote.lifetime || escaped {
366            *mote = LightMote::spawn(
367               &mut rng,
368               self.motion,
369               &self.radius_range,
370               &self.lifetime_range,
371               self.intensity,
372               self.flicker,
373            );
374         }
375
376         // --- Render ---
377         let u = (mote.age / mote.lifetime).clamp(0.0, 1.0);
378         let envelope = envelope(u);
379         // Radius and intensity shrink with age for rising embers.
380         let shrink = match self.motion {
381            LightMotion::Drift => 1.0,
382            LightMotion::Rise => 1.0 - 0.5 * u,
383         };
384         let flicker = 1.0
385            - mote.flicker_depth
386               * 0.5
387               * (1.0 + (mote.phase + time as f32 * mote.flicker_rate).sin());
388         // A big disc must not dump more light than a small one, so scale its
389         // peak alpha down with its radius.
390         let attenuation = size_attenuation(
391            mote.radius,
392            *self.radius_range.start(),
393            *self.radius_range.end(),
394         );
395         let alpha = (mote.intensity * shrink * envelope * flicker * attenuation).clamp(0.0, 1.0);
396
397         let pixel_pos = Pos2::new(
398            area.min.x + mote.pos.x * area.width(),
399            area.min.y + mote.pos.y * area.height(),
400         );
401         let color = self.color.gamma_multiply(alpha);
402         mesh.append(glow_mesh(pixel_pos, mote.radius * shrink, color));
403      }
404
405      // Return motes to state
406      ctx.data_mut(|data| {
407         let state = data.get_temp_mut_or_insert_with(self.id, || LightState::new(seed));
408         state.motes = motes;
409         state.rng = rng;
410      });
411
412      painter.add(egui::Shape::mesh(mesh));
413      ctx.request_repaint();
414   }
415}
416
417/// Life envelope over `u = age / lifetime`.
418///
419/// Fades in over the first 20 % and out over the last 30 %; exactly `0` at both
420/// ends so nothing pops in or out.
421fn envelope(u: f32) -> f32 {
422   let u = u.clamp(0.0, 1.0);
423   if u < 0.2 {
424      u / 0.2
425   } else if u > 0.7 {
426      (1.0 - u) / 0.3
427   } else {
428      1.0
429   }
430}
431
432#[cfg(test)]
433mod tests {
434   use super::*;
435   use egui::__run_test_ctx;
436
437   #[test]
438   fn glow_mesh_vertex_and_index_counts() {
439      let mesh = glow_mesh(Pos2::ZERO, 100.0, Color32::WHITE);
440      // radius / 4 == 25 segments -> 1 centre + 3 * 25 ring vertices.
441      assert_eq!(mesh.vertices.len(), 76);
442      // 3 indices for the core fan + 12 for the two halo quad strips, per segment.
443      assert_eq!(mesh.indices.len(), 375);
444      assert_eq!(mesh.texture_id, TextureId::default());
445   }
446
447   #[test]
448   fn glow_mesh_ring_alphas_fall_off() {
449      let segments = 25;
450      let mesh = glow_mesh(Pos2::ZERO, 100.0, Color32::from_white_alpha(255));
451      let center = mesh.vertices[0].color;
452      let core = mesh.vertices[1].color;
453      let inner = mesh.vertices[1 + segments].color;
454      let rim = mesh.vertices[1 + 2 * segments].color;
455
456      assert!(
457         center.a() > core.a(),
458         "centre must be brighter than the hot-core ring"
459      );
460      assert!(
461         core.a() > inner.a(),
462         "hot-core ring must be brighter than the inner ring"
463      );
464      assert!(
465         inner.a() > rim.a(),
466         "inner ring must be brighter than the rim"
467      );
468      assert_eq!(rim, Color32::TRANSPARENT);
469   }
470
471   #[test]
472   fn glow_mesh_center_tracks_intensity() {
473      let dim = glow_mesh(Pos2::ZERO, 20.0, Color32::from_white_alpha(60));
474      let bright = glow_mesh(Pos2::ZERO, 20.0, Color32::from_white_alpha(220));
475      assert_eq!(dim.vertices[0].color.a(), 60);
476      assert_eq!(bright.vertices[0].color.a(), 220);
477   }
478
479   #[test]
480   fn size_attenuation_is_one_at_min_radius() {
481      assert_eq!(size_attenuation(8.0, 8.0, 36.0), 1.0);
482   }
483
484   #[test]
485   fn size_attenuation_is_non_increasing_across_the_range() {
486      let (min_radius, max_radius) = (8.0, 36.0);
487      let mut previous = f32::INFINITY;
488      for step in 0..=64 {
489         let radius = min_radius + (max_radius - min_radius) * step as f32 / 64.0;
490         let value = size_attenuation(radius, min_radius, max_radius);
491         assert!(
492            value <= previous,
493            "attenuation grew from {previous} to {value} at radius {radius}"
494         );
495         previous = value;
496      }
497   }
498
499   #[test]
500   fn size_attenuation_stays_within_bounds() {
501      let (min_radius, max_radius) = (8.0, 36.0);
502      for step in 0..=64 {
503         let radius = min_radius + (max_radius - min_radius) * step as f32 / 64.0;
504         let value = size_attenuation(radius, min_radius, max_radius);
505         assert!(
506            (0.4..=1.0).contains(&value),
507            "attenuation {value} left 0.4..=1.0 at radius {radius}"
508         );
509      }
510
511      let at_max = size_attenuation(max_radius, min_radius, max_radius);
512      assert!(
513         (0.45..=0.5).contains(&at_max),
514         "attenuation at max radius {at_max} must read about 0.45..=0.5"
515      );
516   }
517
518   #[test]
519   fn peak_alpha_does_not_grow_with_radius() {
520      let (min_radius, max_radius) = (8.0, 36.0);
521      // Mirrors the peak of `Light::show`'s alpha: the envelope and flicker
522      // both reach 1.0 there, and the settings are identical apart from the
523      // mote radius, so the size attenuation is the only difference.
524      let peak_alpha = |radius: f32| {
525         let intensity = 0.8;
526         let shrink = 1.0;
527         let envelope = 1.0;
528         let flicker = 1.0;
529         (intensity
530            * shrink
531            * envelope
532            * flicker
533            * size_attenuation(radius, min_radius, max_radius))
534         .clamp(0.0, 1.0)
535      };
536
537      let small = peak_alpha(min_radius);
538      let large = peak_alpha(max_radius);
539      assert!(
540         large <= small,
541         "a mote at radius_max must not outshine one at radius_min ({large} > {small})"
542      );
543      assert!(
544         small > large,
545         "the peak alpha must actually fall with radius ({small} == {large})"
546      );
547   }
548
549   #[test]
550   fn from_settings_maps_color_density_motion_layer() {
551      let settings = LightSettings {
552         color: Color32::from_rgb(1, 2, 3),
553         density: 13,
554         motion: LightMotion::Rise,
555         layer: ParticleLayer::Top,
556         ..Default::default()
557      };
558
559      let light = Light::from_settings("light_map", &settings);
560      assert_eq!(light.color, Color32::from_rgb(1, 2, 3));
561      assert_eq!(light.density, 13);
562      assert_eq!(light.motion, LightMotion::Rise);
563      assert_eq!(light.layer_order, Order::Debug);
564
565      let background = Light::from_settings(
566         "light_map_bg",
567         &LightSettings {
568            layer: ParticleLayer::Background,
569            ..Default::default()
570         },
571      );
572      assert_eq!(background.layer_order, Order::Background);
573   }
574
575   #[test]
576   fn from_settings_swaps_inverted_ranges() {
577      let settings = LightSettings {
578         radius_min: 40.0,
579         radius_max: 5.0,
580         lifetime_min: 20.0,
581         lifetime_max: 2.0,
582         ..Default::default()
583      };
584
585      let light = Light::from_settings("light_inverted", &settings);
586      assert_eq!(*light.radius_range.start(), 5.0);
587      assert_eq!(*light.radius_range.end(), 40.0);
588      assert_eq!(*light.lifetime_range.start(), 2.0);
589      assert_eq!(*light.lifetime_range.end(), 20.0);
590   }
591
592   #[test]
593   fn show_spawns_density_motes() {
594      __run_test_ctx(|ctx| {
595         let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
596         Light::new("test_light").density(7).area(area).show(ctx);
597
598         let state = ctx
599            .data(|d| d.get_temp::<LightState>(Id::new("test_light")))
600            .expect("LightState should exist after show");
601         assert_eq!(state.motes.len(), 7);
602      });
603   }
604
605   #[test]
606   fn envelope_is_zero_at_both_ends() {
607      assert!(
608         envelope(0.0).abs() < 1e-6,
609         "envelope must start at zero"
610      );
611      assert!(
612         envelope(1.0).abs() < 1e-6,
613         "envelope must end at zero"
614      );
615      assert_eq!(envelope(0.2), 1.0);
616      assert_eq!(envelope(0.5), 1.0);
617      assert_eq!(envelope(0.7), 1.0);
618   }
619
620   #[test]
621   fn large_dt_keeps_motes_contained() {
622      __run_test_ctx(|ctx| {
623         let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
624
625         for frame in 0..120 {
626            ctx.input_mut(|i| {
627               i.stable_dt = 30.0;
628               i.time = f64::from(frame);
629            });
630            Light::new("test_light_large_dt").density(9).area(area).show(ctx);
631         }
632
633         let state = ctx
634            .data(|d| d.get_temp::<LightState>(Id::new("test_light_large_dt")))
635            .expect("LightState should exist after show");
636         assert_eq!(state.motes.len(), 9);
637         for mote in &state.motes {
638            assert!(
639               mote.age <= mote.lifetime,
640               "age {} exceeded lifetime {}",
641               mote.age,
642               mote.lifetime
643            );
644            assert!(
645               (0.0..=1.0).contains(&mote.pos.x),
646               "x {} left the area",
647               mote.pos.x
648            );
649            assert!(
650               (0.0..=1.0).contains(&mote.pos.y),
651               "y {} left the area",
652               mote.pos.y
653            );
654         }
655      });
656   }
657
658   #[test]
659   fn large_dt_keeps_rising_motes_contained() {
660      __run_test_ctx(|ctx| {
661         let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
662         let id = Id::new("test_light_rise_large_dt");
663
664         // Smallest `y` any ember reached. It proves the run actually drove
665         // embers into the top edge, and stays above zero only when they are
666         // recycled instead of piling up on it.
667         let mut min_y = f32::INFINITY;
668
669         for frame in 0..120 {
670            ctx.input_mut(|i| {
671               i.stable_dt = 30.0;
672               i.time = f64::from(frame);
673            });
674            Light::new("test_light_rise_large_dt")
675               .density(9)
676               .motion(LightMotion::Rise)
677               .area(area)
678               .show(ctx);
679
680            let state = ctx
681               .data(|d| d.get_temp::<LightState>(id))
682               .expect("LightState should exist after show");
683            for mote in &state.motes {
684               min_y = min_y.min(mote.pos.y);
685            }
686         }
687
688         let state = ctx
689            .data(|d| d.get_temp::<LightState>(id))
690            .expect("LightState should exist after show");
691         assert_eq!(state.motes.len(), 9);
692         for mote in &state.motes {
693            assert!(
694               mote.age <= mote.lifetime,
695               "age {} exceeded lifetime {}",
696               mote.age,
697               mote.lifetime
698            );
699            assert!(
700               (0.0..=1.0).contains(&mote.pos.x),
701               "x {} left the area",
702               mote.pos.x
703            );
704            assert!(
705               (0.0..=1.0).contains(&mote.pos.y),
706               "y {} left the area",
707               mote.pos.y
708            );
709         }
710
711         assert!(
712            min_y < 0.1,
713            "no rising ember reached the top edge (min y {min_y})"
714         );
715         assert!(
716            min_y > 0.0,
717            "an ember was left resting on the top edge (min y {min_y})"
718         );
719      });
720   }
721
722   #[test]
723   fn degenerate_area_is_a_noop() {
724      __run_test_ctx(|ctx| {
725         let area = Rect::from_min_size(Pos2::ZERO, vec2(0.0, 64.0));
726         Light::new("test_light_degenerate").density(5).area(area).show(ctx);
727         assert!(
728            ctx.data(|d| d.get_temp::<LightState>(Id::new("test_light_degenerate")))
729               .is_none(),
730            "a degenerate area must not create state"
731         );
732      });
733   }
734}