Skip to main content

facett_core/
effects.rs

1//! **effects** — motion + bloom for the facett look. Pure egui, **glow backend
2//! only**: everything here paints with layered alpha shapes on an
3//! [`egui::Painter`], so it works without a custom GPU shader.
4//!
5//! Three layers:
6//! 1. [`easing`] — `t∈[0,1] → [0,1]` tweens (cubic, back, elastic, bounce).
7//! 2. **bloom helpers** — [`glow_rect`], [`glow_text`], [`shimmer`], and a tiny
8//!    [`ParticleBurst`].
9//! 3. [`RavenSprite`] — the signature effect: a raven flies in along a bezier
10//!    arc with ease-out and *perches* on a target [`egui::Rect`], then idle-bobs.
11//!
12//! # Where the GPU path lands later
13//! True bloom (a bright-pass + separable Gaussian on a HDR target) and the raven
14//! as a textured/instanced sprite belong in the **wgpu kernel** (see
15//! `.nornir/wgpu-plan.md`): they'd replace the layered-alpha [`glow_rect`] /
16//! shape-painted raven here behind the same call sites. Until then this CPU path
17//! is the reference and is fully headless-testable.
18
19use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
20
21/// `t∈[0,1] → [0,1]` tweening curves. Each is clamped at the ends so
22/// `f(0.0) == 0.0` and `f(1.0) == 1.0` exactly (the demo + tests rely on it).
23pub mod easing {
24    /// Identity ramp.
25    pub fn linear(t: f32) -> f32 {
26        t.clamp(0.0, 1.0)
27    }
28
29    /// Smooth accelerate-then-decelerate (the workhorse).
30    pub fn ease_in_out_cubic(t: f32) -> f32 {
31        let t = t.clamp(0.0, 1.0);
32        if t < 0.5 {
33            4.0 * t * t * t
34        } else {
35            let f = -2.0 * t + 2.0;
36            1.0 - f * f * f / 2.0
37        }
38    }
39
40    /// Overshoots slightly past 1.0 near the end, then settles — a confident
41    /// "snap into place". Endpoints are still exactly 0 and 1.
42    pub fn ease_out_back(t: f32) -> f32 {
43        let t = t.clamp(0.0, 1.0);
44        const C1: f32 = 1.70158;
45        const C3: f32 = C1 + 1.0;
46        let f = t - 1.0;
47        1.0 + C3 * f * f * f + C1 * f * f
48    }
49
50    /// Springy elastic settle (decaying sine). `f(0)=0`, `f(1)=1`.
51    pub fn elastic(t: f32) -> f32 {
52        let t = t.clamp(0.0, 1.0);
53        if t == 0.0 || t == 1.0 {
54            return t;
55        }
56        const C4: f32 = std::f32::consts::TAU / 3.0;
57        2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
58    }
59
60    /// Gravity bounce-to-rest. `f(0)=0`, `f(1)=1`.
61    pub fn bounce(t: f32) -> f32 {
62        let t = t.clamp(0.0, 1.0);
63        const N1: f32 = 7.5625;
64        const D1: f32 = 2.75;
65        if t < 1.0 / D1 {
66            N1 * t * t
67        } else if t < 2.0 / D1 {
68            let t = t - 1.5 / D1;
69            N1 * t * t + 0.75
70        } else if t < 2.5 / D1 {
71            let t = t - 2.25 / D1;
72            N1 * t * t + 0.9375
73        } else {
74            let t = t - 2.625 / D1;
75            N1 * t * t + 0.984375
76        }
77    }
78}
79
80/// Linear interpolation between two colours in straight (unmultiplied) space.
81fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
82    let t = t.clamp(0.0, 1.0);
83    let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
84    Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
85}
86
87/// Paint a soft **bloom** around `rect`: `layers` concentric rounded strokes that
88/// fade out as they expand. `intensity∈[0,1]` scales the alpha (e.g. animate it
89/// with a [`easing`] curve for a pulse). Cheap stand-in for true HDR bloom.
90pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
91    let intensity = intensity.clamp(0.0, 1.0);
92    let layers = layers.max(1);
93    for i in 0..layers {
94        let f = i as f32 / layers as f32; // 0 (inner) .. ~1 (outer)
95        let grow = 1.0 + f * 7.0;
96        let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
97        if alpha == 0 {
98            continue;
99        }
100        let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
101        painter.rect_stroke(
102            rect.expand(grow),
103            4.0 + grow,
104            Stroke::new(1.5 + f * 2.0, c),
105            egui::StrokeKind::Outside,
106        );
107    }
108}
109
110/// Draw `text` at `pos` with a coloured **glow halo** behind it (offset copies
111/// in a ring), then the crisp text on top. `glow` is usually `theme.glow`.
112#[allow(clippy::too_many_arguments)]
113pub fn glow_text(
114    painter: &Painter,
115    pos: Pos2,
116    anchor: egui::Align2,
117    text: &str,
118    font: egui::FontId,
119    text_color: Color32,
120    glow: Color32,
121    intensity: f32,
122) {
123    let intensity = intensity.clamp(0.0, 1.0);
124    let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
125    for r in [3.0_f32, 2.0, 1.0] {
126        for k in 0..8 {
127            let a = std::f32::consts::TAU * k as f32 / 8.0;
128            let off = vec2(a.cos(), a.sin()) * r;
129            painter.text(pos + off, anchor, text, font.clone(), halo);
130        }
131    }
132    painter.text(pos, anchor, text, font, text_color);
133}
134
135/// Paint an animated **shimmer** sweep across `rect`: a diagonal highlight band
136/// at phase `t∈[0,1]` (wrap `t` yourself, e.g. `(time * speed).fract()`). Built
137/// from a handful of vertical alpha bars, brightest at the band centre.
138pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
139    let bars = 24;
140    let band = 0.18; // half-width of the highlight, in [0,1] of the sweep
141    let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
142    for i in 0..bars {
143        let x = (i as f32 + 0.5) / bars as f32; // 0..1 across the rect
144        let d = (x - center).abs() / band;
145        if d >= 1.0 {
146            continue;
147        }
148        let g = (1.0 - d) * (1.0 - d); // brightness, 0 at edges → 1 at band centre
149        // Brighten toward white at the band centre for a glinting highlight,
150        // then carry that as a translucent overlay.
151        let bright = lerp_color(color, Color32::WHITE, g * 0.6);
152        let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
153        let bx0 = rect.left() + x * rect.width();
154        let bw = rect.width() / bars as f32 + 1.0;
155        // a gentle diagonal: shear the bar top by the band offset
156        let shear = (x - 0.5) * rect.height() * 0.25;
157        let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
158            .intersect(rect);
159        painter.rect_filled(seg, 0.0, c);
160    }
161}
162
163/// One particle: position, velocity, age. Internal to [`ParticleBurst`].
164#[derive(Clone, Copy)]
165struct Particle {
166    pos: Pos2,
167    vel: Vec2,
168    /// Seconds lived.
169    age: f32,
170}
171
172/// A tiny **particle burst** — circles fired outward from a point that fall under
173/// gravity and fade out. Deterministic given the same `seed`, so a test can pin
174/// it. Advance with [`ParticleBurst::update`], render with
175/// [`ParticleBurst::paint`].
176#[derive(Clone)]
177pub struct ParticleBurst {
178    particles: Vec<Particle>,
179    color: Color32,
180    /// px/s² downward.
181    gravity: f32,
182    /// Total lifetime of a particle, seconds.
183    lifetime: f32,
184    elapsed: f32,
185}
186
187impl ParticleBurst {
188    /// Fire `count` particles from `origin` outward in a ring, with speeds
189    /// jittered by `seed` (deterministic — no RNG crate).
190    pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
191        let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
192        let mut rng = || {
193            // SplitMix64 — deterministic, dependency-free.
194            h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
195            let mut z = h;
196            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
197            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
198            ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
199        };
200        let particles = (0..count)
201            .map(|i| {
202                let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
203                let speed = 90.0 + rng() * 140.0;
204                let up = 0.6 + rng() * 0.4; // bias upward so they arc nicely
205                Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
206            })
207            .collect();
208        Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
209    }
210
211    /// Advance every particle by `dt` seconds (integrate velocity + gravity).
212    pub fn update(&mut self, dt: f32) {
213        let dt = dt.max(0.0);
214        self.elapsed += dt;
215        for p in &mut self.particles {
216            p.vel.y += self.gravity * dt;
217            p.pos += p.vel * dt;
218            p.age += dt;
219        }
220    }
221
222    /// All particles have aged past their lifetime — the burst is done and can
223    /// be dropped.
224    pub fn finished(&self) -> bool {
225        self.elapsed >= self.lifetime
226    }
227
228    /// Paint each live particle as a fading circle.
229    pub fn paint(&self, painter: &Painter) {
230        for p in &self.particles {
231            let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
232            if life <= 0.0 {
233                continue;
234            }
235            let a = (life * 220.0) as u8;
236            let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
237            painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
238        }
239    }
240}
241
242/// Quadratic bezier point at `t∈[0,1]` for control points `p0,p1,p2`.
243fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
244    let u = 1.0 - t;
245    let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
246    let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
247    pos2(x, y)
248}
249
250/// The flight duration (seconds) of a [`RavenSprite`] from launch to perch.
251pub const RAVEN_FLIGHT_SECS: f32 = 1.4;
252
253/// **The signature effect.** A stylized raven 🐦 (painted from shapes — no asset
254/// dependency, so it never goes missing) that **flies in along a bezier arc with
255/// ease-out and perches** on a target [`egui::Rect`], then idle-bobs.
256///
257/// Motion is **deterministic given `(start, target, elapsed)`** — that's what
258/// makes [`RavenSprite::pos_at`] unit-testable. The egui-driven loop is:
259///
260/// ```ignore
261/// raven.update(ctx);       // reads ctx.input(i.time), requests a repaint while flying
262/// raven.paint(&painter);   // draws the bird at its current pos
263/// ```
264#[derive(Clone)]
265pub struct RavenSprite {
266    start: Pos2,
267    target: Pos2,
268    /// `None` until the first [`update`](Self::update) pins the launch time.
269    launch_time: Option<f64>,
270    /// Filled by [`update`](Self::update) each frame.
271    current: Pos2,
272    /// 0 while flying, 1 once perched.
273    perched: bool,
274    /// Seconds since launch (drives the idle bob once perched).
275    elapsed: f32,
276    color: Color32,
277    /// Face left (-1) or right (+1) — set from the flight direction.
278    facing: f32,
279    scale: f32,
280}
281
282impl Default for RavenSprite {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288impl RavenSprite {
289    /// A raven waiting off the top-left, ready to be aimed with [`fly_to`].
290    pub fn new() -> Self {
291        Self {
292            start: pos2(-40.0, -40.0),
293            target: pos2(0.0, 0.0),
294            launch_time: None,
295            current: pos2(-40.0, -40.0),
296            perched: false,
297            elapsed: 0.0,
298            color: Color32::from_rgb(18, 18, 22),
299            facing: 1.0,
300            scale: 1.0,
301        }
302    }
303
304    /// Launch point of the flight (defaults to off-screen top-left).
305    pub fn from(mut self, start: Pos2) -> Self {
306        self.start = start;
307        self.current = start;
308        self
309    }
310
311    /// Raven body colour (override the default near-black, e.g. for `hugin_noir`
312    /// you might tint the beak/eye via `accent` separately).
313    pub fn color(mut self, color: Color32) -> Self {
314        self.color = color;
315        self
316    }
317
318    /// Overall sprite scale (1.0 ≈ a ~14px-tall bird body).
319    pub fn scale(mut self, scale: f32) -> Self {
320        self.scale = scale.max(0.1);
321        self
322    }
323
324    /// **Aim the raven** at `target`: it will perch centred on the top edge of
325    /// the rect (like landing on a table row). Resets the flight clock.
326    pub fn fly_to(mut self, target: Rect) -> Self {
327        // Perch on the top edge, centred — feet just above the row.
328        self.target = pos2(target.center().x, target.top());
329        self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
330        self.launch_time = None;
331        self.perched = false;
332        self
333    }
334
335    /// The flight path control point — a high arc above the midpoint, so the
336    /// raven swoops down onto the perch. Pure function of `start`/`target`.
337    fn control(&self) -> Pos2 {
338        let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
339        let span = (self.target - self.start).length().max(1.0);
340        pos2(mid.x, mid.y - span * 0.45) // lift the arc
341    }
342
343    /// **Deterministic position** at `elapsed` seconds since launch — the testable
344    /// core. Eases along the bezier arc with [`easing::ease_out_back`] (a
345    /// confident landing), holds the perch afterwards. Adds a subtle idle bob
346    /// once perched.
347    pub fn pos_at(&self, elapsed: f32) -> Pos2 {
348        if elapsed >= RAVEN_FLIGHT_SECS {
349            // Perched: tiny vertical bob (±1.5px, ~0.6Hz).
350            let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
351            return pos2(self.target.x, self.target.y + bob);
352        }
353        let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
354        let t = easing::ease_out_back(lin);
355        bezier2(self.start, self.control(), self.target, t)
356    }
357
358    /// True once the flight is over (raven is perched, idle-bobbing).
359    pub fn is_perched(&self) -> bool {
360        self.perched
361    }
362
363    /// Current sprite position (set by the last [`update`](Self::update)).
364    pub fn pos(&self) -> Pos2 {
365        self.current
366    }
367
368    /// Drive the animation from the egui clock. Pins the launch time on first
369    /// call, advances `current`, and `request_repaint`s while still in flight so
370    /// the animation keeps ticking. Call once per frame before [`paint`].
371    pub fn update(&mut self, ctx: &egui::Context) {
372        let now = ctx.input(|i| i.time);
373        let launch = *self.launch_time.get_or_insert(now);
374        let elapsed = (now - launch) as f32;
375        self.advance(elapsed);
376        if !self.perched {
377            ctx.request_repaint();
378        }
379    }
380
381    /// Time-driven core shared by [`update`] and the headless test: set the
382    /// current pos + facing + perched flag for `elapsed` seconds since launch.
383    pub fn advance(&mut self, elapsed: f32) {
384        self.elapsed = elapsed;
385        self.current = self.pos_at(elapsed);
386        self.perched = elapsed >= RAVEN_FLIGHT_SECS;
387    }
388
389    /// Paint the raven at its current position. A stylized corvid built from a
390    /// body ellipse-ish blob, swept wings, a wedge tail, a head, and a beak —
391    /// wings flap while flying and fold once perched.
392    pub fn paint(&self, painter: &Painter) {
393        let c = self.current;
394        let s = self.scale;
395        let f = self.facing;
396        let body = self.color;
397        let stroke = Stroke::new(1.0 * s, body);
398
399        // Wing phase: flapping in flight, near-folded when perched.
400        let flap = if self.perched {
401            0.15
402        } else {
403            // 0..1..0 flap, ~5Hz
404            (self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
405        };
406        let wing_lift = (flap - 0.5) * 9.0 * s; // up/down sweep of the wingtip
407
408        // Body: a rounded blob (overlapping circles read as a corvid body).
409        painter.circle_filled(c, 5.0 * s, body);
410        painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);
411
412        // Tail: a wedge trailing behind (opposite the facing direction).
413        let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
414        painter.add(egui::Shape::convex_polygon(
415            vec![
416                tail_root,
417                tail_root + vec2(-7.0 * s * f, -2.5 * s),
418                tail_root + vec2(-7.5 * s * f, 1.0 * s),
419                tail_root + vec2(-6.0 * s * f, 3.0 * s),
420            ],
421            body,
422            stroke,
423        ));
424
425        // Wings: two swept triangles from the back, lifting with the flap.
426        let shoulder = c + vec2(-s * f, -1.5 * s);
427        let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
428        let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
429        painter.add(egui::Shape::convex_polygon(
430            vec![shoulder, tip_far, tip_near],
431            body,
432            stroke,
433        ));
434
435        // Head + beak, leading the body in the facing direction.
436        let head = c + vec2(4.5 * s * f, -2.5 * s);
437        painter.circle_filled(head, 3.0 * s, body);
438        let beak_color = Color32::from_rgb(40, 30, 18); // charcoal beak
439        painter.add(egui::Shape::convex_polygon(
440            vec![
441                head + vec2(2.5 * s * f, -0.5 * s),
442                head + vec2(6.5 * s * f, 0.5 * s),
443                head + vec2(2.5 * s * f, 1.5 * s),
444            ],
445            beak_color,
446            Stroke::NONE,
447        ));
448        // Eye: a tiny bright glint so it reads as alive.
449        painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    /// A named easing fn (keeps the test table readable / clippy-clean).
458    type NamedEasing = (&'static str, fn(f32) -> f32);
459
460    fn approx(a: f32, b: f32, eps: f32) -> bool {
461        (a - b).abs() <= eps
462    }
463
464    #[test]
465    fn easing_fns_hit_their_endpoints() {
466        let fns: [NamedEasing; 5] = [
467            ("linear", easing::linear),
468            ("cubic", easing::ease_in_out_cubic),
469            ("back", easing::ease_out_back),
470            ("elastic", easing::elastic),
471            ("bounce", easing::bounce),
472        ];
473        for (name, f) in fns {
474            assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
475            assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
476            // clamped outside [0,1]
477            assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
478            assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
479        }
480    }
481
482    #[test]
483    fn ease_out_back_overshoots_before_settling() {
484        // The "back" curve should exceed 1.0 somewhere near the end.
485        let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
486        assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
487    }
488
489    #[test]
490    fn raven_starts_at_launch_and_converges_onto_target_rect() {
491        let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
492        let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);
493
494        // At t=0 it's at the launch point, not perched.
495        raven.advance(0.0);
496        assert!(!raven.is_perched());
497        assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");
498
499        // Midway it's airborne and somewhere between start and target (and lifted
500        // by the arc — above the straight line).
501        raven.advance(RAVEN_FLIGHT_SECS * 0.5);
502        assert!(!raven.is_perched());
503
504        // After the full flight duration it has perched on the target's top edge,
505        // centred, within a tiny bob amplitude.
506        raven.advance(RAVEN_FLIGHT_SECS);
507        assert!(raven.is_perched(), "perched after flight duration");
508        let perch = pos2(target.center().x, target.top());
509        let d = (raven.pos() - perch).length();
510        assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");
511
512        // And it stays on the perch (within the bob) for a while after.
513        for k in 1..20 {
514            raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
515            assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
516            assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
517        }
518    }
519
520    #[test]
521    fn particle_burst_falls_and_finishes() {
522        let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
523        assert!(!b.finished());
524        for _ in 0..120 {
525            b.update(1.0 / 60.0);
526        }
527        assert!(b.finished(), "burst should expire after its lifetime");
528        // deterministic given the seed
529        let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
530        let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
531        a.update(0.1);
532        c.update(0.1);
533        assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
534    }
535}