1use 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
13const DRIFT_SPEED: f32 = 20.0;
15const DRIFT_RESPONSE: f32 = 6.0;
17const RISE_SWAY: f32 = 18.0;
19const RISE_SPEED: f32 = 120.0;
21
22#[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#[derive(Clone, Copy)]
40struct LightMote {
41 pos: Pos2,
43 vel: Vec2,
45 radius: f32,
47 intensity: f32,
49 phase: f32,
51 flicker_rate: f32,
53 flicker_depth: f32,
55 age: f32,
57 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 fn advance(&mut self, motion: LightMotion, wind: Vec2, time: f32, dt: f32, area: Rect) -> bool {
93 match motion {
94 LightMotion::Drift => {
95 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 let escaped = matches!(motion, LightMotion::Rise) && self.pos.y < 0.0;
115
116 if matches!(motion, LightMotion::Drift) {
117 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 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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum LightMotion {
146 Drift,
148 Rise,
150}
151
152impl LightMotion {
153 pub fn to_str(&self) -> &str {
155 match self {
156 LightMotion::Drift => "Drift",
157 LightMotion::Rise => "Rise",
158 }
159 }
160}
161
162#[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 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 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 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 pub fn color(mut self, color: Color32) -> Self {
236 self.color = color;
237 self
238 }
239
240 pub fn density(mut self, density: usize) -> Self {
242 self.density = density;
243 self
244 }
245
246 pub fn radius(mut self, range: RangeInclusive<f32>) -> Self {
248 self.radius_range = range;
249 self
250 }
251
252 pub fn intensity(mut self, intensity: f32) -> Self {
254 self.intensity = intensity.clamp(0.0, 1.0);
255 self
256 }
257
258 pub fn motion(mut self, motion: LightMotion) -> Self {
260 self.motion = motion;
261 self
262 }
263
264 pub fn wind(mut self, wind: impl Into<Vec2>) -> Self {
266 self.wind = wind.into();
267 self
268 }
269
270 pub fn flicker(mut self, flicker: f32) -> Self {
272 self.flicker = flicker.clamp(0.0, 1.0);
273 self
274 }
275
276 pub fn lifetime(mut self, range: RangeInclusive<f32>) -> Self {
278 self.lifetime_range = range;
279 self
280 }
281
282 pub fn on_top(mut self) -> Self {
284 self.layer_order = Order::Debug;
285 self
286 }
287
288 pub fn on_foreground(mut self) -> Self {
290 self.layer_order = Order::Foreground;
291 self
292 }
293
294 pub fn on_background(mut self) -> Self {
296 self.layer_order = Order::Background;
297 self
298 }
299
300 pub fn layer(mut self, layer_id: LayerId) -> Self {
304 self.custom_layer = Some(layer_id);
305 self
306 }
307
308 pub fn area(mut self, area: Rect) -> Self {
312 self.custom_area = Some(area);
313 self
314 }
315
316 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 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 mote.age += dt;
362 let escaped = mote.advance(self.motion, self.wind, time as f32, dt, area);
363 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 let u = (mote.age / mote.lifetime).clamp(0.0, 1.0);
378 let envelope = envelope(u);
379 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 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 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
417fn 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 assert_eq!(mesh.vertices.len(), 76);
442 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 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 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}