Skip to main content

pfx_live/flat/effects/
mod.rs

1pub mod blur;
2pub mod shards;
3#[cfg(test)]
4mod tests;
5#[cfg(test)]
6mod tests_swarm;
7
8use super::{Draw, Fill, IDENTITY, Light, Matrix, Shadow, Shape, Srgba, Stroke, multiply, place};
9use pfx_core::clock::{Clock, HitStop, Tick};
10use pfx_core::ease::Ease;
11pub use pfx_core::fx::{
12    Comfort, Curve, FLASHES_PER_SECOND, FlashGate, Response, Shake, ShakeOffset, ShakeTuning,
13};
14use pfx_core::sim::Rng;
15use pfx_post::tape::Tape;
16pub use shards::{Shards, Shatter};
17
18pub const FLASH_ELEVATION: f32 = f32::MAX;
19
20#[derive(Clone, Copy, Debug, Default, PartialEq)]
21pub struct Responses {
22    pub shake: Response,
23    pub burst: Response,
24    pub ring: Response,
25    pub flash: Response,
26    pub blur: Response,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct Fade {
31    pub from: Srgba,
32    pub to: Srgba,
33    pub ease: Ease,
34}
35
36impl Fade {
37    pub fn at(&self, t: f32) -> Srgba {
38        let k = self.ease.at(t.clamp(0.0, 1.0));
39        Srgba(std::array::from_fn(|i| {
40            self.from.0[i] + (self.to.0[i] - self.from.0[i]) * k
41        }))
42    }
43}
44
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Burst {
47    pub shape: Shape,
48    pub fill: Fill,
49    pub stroke: Option<Stroke>,
50    pub count: u32,
51    pub speed: [f32; 2],
52    pub direction: f32,
53    pub spread: f32,
54    pub gravity: [f32; 2],
55    pub drag: f32,
56    pub spin: [f32; 2],
57    pub life: [f32; 2],
58    pub size: [f32; 2],
59    pub scale: Curve,
60    pub alpha: Curve,
61    pub colour: Option<Fade>,
62    pub elevation: f32,
63    pub shadow: Shadow,
64    pub shutter: f32,
65    pub additive: bool,
66}
67
68impl Burst {
69    pub fn new(shape: Shape, fill: Fill) -> Self {
70        Self {
71            shape,
72            fill,
73            stroke: None,
74            count: 16,
75            speed: [100.0, 300.0],
76            direction: 0.0,
77            spread: std::f32::consts::TAU,
78            gravity: [0.0, 0.0],
79            drag: 0.0,
80            spin: [0.0, 0.0],
81            life: [0.5, 1.0],
82            size: [1.0, 1.0],
83            scale: Curve::ONE,
84            alpha: Curve::new(1.0, 0.0, Ease::Linear),
85            colour: None,
86            elevation: 0.0,
87            shadow: Shadow::None,
88            shutter: 0.0,
89            additive: false,
90        }
91    }
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct BurstId(u16);
96
97#[derive(Clone, Copy, Debug, PartialEq)]
98struct Particle {
99    recipe: u16,
100    position: [f32; 2],
101    velocity: [f32; 2],
102    angle: f32,
103    spin: f32,
104    size: f32,
105    age: f32,
106    life: f32,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct Ring {
111    pub centre: [f32; 2],
112    pub radius: [f32; 2],
113    pub width: [f32; 2],
114    pub colour: Srgba,
115    pub alpha: Curve,
116    pub life: f32,
117    pub ease: Ease,
118    pub elevation: f32,
119    pub additive: bool,
120}
121
122impl Ring {
123    pub fn new(centre: [f32; 2], radius: [f32; 2], colour: Srgba, life: f32) -> Self {
124        Self {
125            centre,
126            radius,
127            width: [12.0, 2.0],
128            colour,
129            alpha: Curve::new(1.0, 0.0, Ease::QuadIn),
130            life,
131            ease: Ease::QuartOut,
132            elevation: 0.0,
133            additive: false,
134        }
135    }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq)]
139struct LiveRing {
140    ring: Ring,
141    age: f32,
142    life: f32,
143    gain: f32,
144}
145
146#[derive(Clone, Copy, Debug, PartialEq)]
147pub struct Flash {
148    pub colour: Srgba,
149    pub alpha: f32,
150    pub attack: f32,
151    pub release: f32,
152    pub ease: Ease,
153    pub additive: bool,
154}
155
156impl Flash {
157    pub fn new(colour: Srgba, alpha: f32, attack: f32, release: f32) -> Self {
158        Self {
159            colour,
160            alpha,
161            attack,
162            release,
163            ease: Ease::QuadOut,
164            additive: false,
165        }
166    }
167
168    pub fn alpha_at(&self, t: f32) -> f32 {
169        if t.is_nan() || t < 0.0 {
170            return 0.0;
171        }
172        if t < self.attack {
173            return self.alpha * self.ease.at(t / self.attack);
174        }
175        let t = t - self.attack.max(0.0);
176        if t >= self.release {
177            return 0.0;
178        }
179        self.alpha * (1.0 - self.ease.at(t / self.release))
180    }
181
182    pub fn length(&self) -> f32 {
183        self.attack.max(0.0) + self.release.max(0.0)
184    }
185}
186
187#[derive(Clone, Copy, Debug, PartialEq)]
188struct LiveFlash {
189    flash: Flash,
190    age: f32,
191}
192
193#[derive(Clone, Copy, Debug, PartialEq)]
194pub struct EffectsDesc {
195    pub layout: [f32; 2],
196    pub seed: u64,
197    pub particles: usize,
198    pub rings: usize,
199    pub shake: ShakeTuning,
200    pub responses: Responses,
201    pub comfort: Comfort,
202}
203
204impl EffectsDesc {
205    pub fn new(layout: [f32; 2], seed: u64) -> Self {
206        Self {
207            layout,
208            seed,
209            particles: 8192,
210            rings: 64,
211            shake: ShakeTuning::default(),
212            responses: Responses::default(),
213            comfort: Comfort::FULL,
214        }
215    }
216}
217
218pub struct Effects {
219    pub comfort: Comfort,
220    pub intensity: f32,
221    pub responses: Responses,
222    layout: [f32; 2],
223    shake: Shake,
224    rng: Rng,
225    recipes: Vec<Burst>,
226    particles: Vec<Particle>,
227    particle_cap: usize,
228    rings: Vec<LiveRing>,
229    ring_cap: usize,
230    flash: Option<LiveFlash>,
231    gate: FlashGate,
232    real: f64,
233    dropped: u64,
234    out: Vec<Draw>,
235    keep: Vec<f32>,
236}
237
238impl Effects {
239    pub fn new(desc: EffectsDesc) -> Self {
240        Self {
241            comfort: desc.comfort,
242            intensity: 0.0,
243            responses: desc.responses,
244            layout: desc.layout,
245            shake: Shake::new(desc.shake),
246            rng: Rng::new(desc.seed),
247            recipes: Vec::new(),
248            particles: Vec::with_capacity(desc.particles),
249            particle_cap: desc.particles,
250            rings: Vec::with_capacity(desc.rings),
251            ring_cap: desc.rings,
252            flash: None,
253            gate: FlashGate::new(),
254            real: 0.0,
255            dropped: 0,
256            out: Vec::new(),
257            keep: Vec::new(),
258        }
259    }
260
261    pub fn recipe(&mut self, burst: Burst) -> Result<BurstId, String> {
262        let index = u16::try_from(self.recipes.len()).map_err(|_| "too many burst recipes")?;
263        self.recipes.push(burst);
264        Ok(BurstId(index))
265    }
266
267    pub fn recipe_mut(&mut self, id: BurstId) -> Option<&mut Burst> {
268        self.recipes.get_mut(usize::from(id.0))
269    }
270
271    pub fn live(&self) -> usize {
272        self.particles.len()
273    }
274
275    pub fn cap(&self) -> usize {
276        self.particle_cap
277    }
278
279    pub fn dropped(&self) -> u64 {
280        self.dropped
281    }
282
283    pub fn real_time(&self) -> f64 {
284        self.real
285    }
286
287    pub fn shake(&mut self, trauma: f32) {
288        self.shake.add(trauma);
289    }
290
291    pub fn trauma(&self) -> f32 {
292        self.shake.trauma()
293    }
294
295    pub fn burst(&mut self, id: BurstId, at: [f32; 2]) -> u32 {
296        let direction = self
297            .recipes
298            .get(usize::from(id.0))
299            .map_or(0.0, |recipe| recipe.direction);
300        self.burst_toward(id, at, direction)
301    }
302
303    pub fn burst_toward(&mut self, id: BurstId, at: [f32; 2], direction: f32) -> u32 {
304        let Some(recipe) = self.recipes.get(usize::from(id.0)) else {
305            return 0;
306        };
307        let response = self.responses.burst;
308        let count = (recipe.count as f32 * response.gain(self.intensity)).round() as u32;
309        let rate = response.rate(self.intensity);
310        let mut spawned = 0;
311        for _ in 0..count {
312            if self.particles.len() >= self.particle_cap {
313                self.dropped += u64::from(count - spawned);
314                break;
315            }
316            let rng = &mut self.rng;
317            let angle = direction + (rng.next_f32() - 0.5) * recipe.spread;
318            let speed = between(rng, recipe.speed) * rate;
319            let life = between(rng, recipe.life).max(1e-3);
320            self.particles.push(Particle {
321                recipe: id.0,
322                position: at,
323                velocity: [angle.cos() * speed, angle.sin() * speed],
324                angle: rng.next_f32() * std::f32::consts::TAU,
325                spin: between(rng, recipe.spin) * if rng.next_bool() { 1.0 } else { -1.0 },
326                size: between(rng, recipe.size),
327                age: 0.0,
328                life,
329            });
330            spawned += 1;
331        }
332        spawned
333    }
334
335    pub fn ring(&mut self, ring: Ring) -> bool {
336        if self.rings.len() >= self.ring_cap || ring.life.is_nan() || ring.life <= 0.0 {
337            return false;
338        }
339        let response = self.responses.ring;
340        let rate = response.rate(self.intensity).max(1e-3);
341        self.rings.push(LiveRing {
342            ring,
343            age: 0.0,
344            life: ring.life / rate,
345            gain: response.gain(self.intensity),
346        });
347        true
348    }
349
350    pub fn flash(&mut self, flash: Flash) -> bool {
351        let response = self.responses.flash;
352        let rate = response.rate(self.intensity).max(1e-3);
353        let alpha = self
354            .comfort
355            .flash(flash.alpha * response.gain(self.intensity))
356            .clamp(0.0, 1.0);
357        if alpha.is_nan() || alpha <= 0.0 || !self.gate.admit(self.real) {
358            return false;
359        }
360        self.flash = Some(LiveFlash {
361            flash: Flash {
362                alpha,
363                attack: flash.attack.max(0.0) / rate,
364                release: flash.release.max(0.0) / rate,
365                ..flash
366            },
367            age: 0.0,
368        });
369        true
370    }
371
372    pub fn hit_stop(&self, clock: &mut Clock, stop: HitStop) {
373        clock.hit_stop(self.comfort.hit_stop(stop));
374    }
375
376    pub fn tape(&self, tape: Tape) -> Tape {
377        Tape {
378            strength: self.comfort.glitch(tape.strength),
379            ..tape
380        }
381    }
382
383    pub fn motion(&self, velocity: [f32; 2], shutter: f32) -> [f32; 2] {
384        let gain = self.responses.blur.gain(self.intensity) * shutter.max(0.0);
385        velocity.map(|value| value * gain)
386    }
387
388    pub fn step(&mut self, tick: &Tick) {
389        self.real += f64::from(tick.real.max(0.0));
390        let dt = tick.dt.max(0.0);
391        let paced = tick.paced.max(0.0);
392        self.shake
393            .step(paced, self.responses.shake.rate(self.intensity));
394        if let Some(live) = self.flash.as_mut() {
395            live.age += paced;
396            if live.age >= live.flash.length() {
397                self.flash = None;
398            }
399        }
400        if dt <= 0.0 {
401            return;
402        }
403        self.keep.clear();
404        self.keep.extend(
405            self.recipes
406                .iter()
407                .map(|recipe| (-recipe.drag.max(0.0) * dt).exp()),
408        );
409        let mut index = 0;
410        while index < self.particles.len() {
411            let particle = &mut self.particles[index];
412            particle.age += dt;
413            if particle.age >= particle.life {
414                self.particles.swap_remove(index);
415                continue;
416            }
417            let recipe = &self.recipes[usize::from(particle.recipe)];
418            let keep = self.keep[usize::from(particle.recipe)];
419            for axis in 0..2 {
420                particle.velocity[axis] =
421                    (particle.velocity[axis] + recipe.gravity[axis] * dt) * keep;
422                particle.position[axis] += particle.velocity[axis] * dt;
423            }
424            particle.angle += particle.spin * dt;
425            index += 1;
426        }
427        let mut index = 0;
428        while index < self.rings.len() {
429            let ring = &mut self.rings[index];
430            ring.age += dt;
431            if ring.age >= ring.life {
432                self.rings.swap_remove(index);
433            } else {
434                index += 1;
435            }
436        }
437    }
438
439    pub fn offset(&self) -> ShakeOffset {
440        let gain = self
441            .comfort
442            .shake(self.responses.shake.gain(self.intensity));
443        self.shake.offset(gain)
444    }
445
446    pub fn camera(&self) -> Matrix {
447        camera(self.offset(), self.layout)
448    }
449
450    pub fn compose(&mut self, draws: &[Draw], light: Light) -> (&[Draw], Light) {
451        self.out.clear();
452        let offset = self.offset();
453        let camera = (!offset.is_zero()).then(|| camera(offset, self.layout));
454        let motion = self.comfort.amount();
455        let carry = |draw: Draw| shaken(draw, camera, motion);
456        self.out.extend(draws.iter().copied().map(carry));
457        let blur_gain = self.responses.blur.gain(self.intensity);
458        for particle in &self.particles {
459            let recipe = &self.recipes[usize::from(particle.recipe)];
460            let t = particle.age / particle.life;
461            let size = particle.size * recipe.scale.at(t);
462            let alpha = recipe.alpha.at(t).clamp(0.0, 1.0);
463            if !(size > 0.0 && alpha > 0.0) {
464                continue;
465            }
466            let transform = multiply(
467                place(particle.position, particle.angle),
468                super::scaling(size, size),
469            );
470            let fill = match recipe.colour {
471                Some(fade) => Fill::Solid(fade.at(t)),
472                None => recipe.fill,
473            };
474            let mut draw = Draw::new(recipe.shape)
475                .transform(transform)
476                .elevation(recipe.elevation)
477                .shadow(recipe.shadow)
478                .opacity(alpha)
479                .unpicked();
480            draw.fill = fill;
481            draw.stroke = recipe.stroke;
482            draw.fx.additive = recipe.additive;
483            if recipe.shutter > 0.0 {
484                let gain = recipe.shutter * blur_gain;
485                draw.fx.blur = particle.velocity.map(|value| value * gain);
486            }
487            self.out.push(carry(draw));
488        }
489        for live in &self.rings {
490            let ring = live.ring;
491            let t = live.age / live.life;
492            let k = ring.ease.at(t);
493            let radius = ring.radius[0] + (ring.radius[1] - ring.radius[0]) * k;
494            let width = ring.width[0] + (ring.width[1] - ring.width[0]) * k;
495            let alpha = (ring.alpha.at(t) * live.gain).clamp(0.0, 1.0);
496            if !(radius > 0.0 && width > 0.0 && alpha > 0.0) {
497                continue;
498            }
499            let mut draw = Draw::new(Shape::Ring { radius, width })
500                .at(ring.centre)
501                .fill(ring.colour)
502                .opacity(alpha)
503                .elevation(ring.elevation)
504                .shadow(Shadow::None)
505                .unpicked();
506            draw.fx.additive = ring.additive;
507            self.out.push(carry(draw));
508        }
509        if let Some(live) = self.flash {
510            let alpha = live.flash.alpha_at(live.age).clamp(0.0, 1.0);
511            if alpha > 0.0 {
512                let [w, h] = self.layout;
513                let mut draw = Draw::rect([w * 0.5, h * 0.5], [w, h], 0.0)
514                    .fill(live.flash.colour)
515                    .opacity(alpha)
516                    .elevation(FLASH_ELEVATION)
517                    .shadow(Shadow::None)
518                    .unpicked();
519                draw.fx.additive = live.flash.additive;
520                self.out.push(draw);
521            }
522        }
523        let light = if offset.roll != 0.0 {
524            let (s, c) = offset.roll.sin_cos();
525            let [x, y] = light.shadow;
526            Light {
527                shadow: [c * x - s * y, s * x + c * y],
528                ..light
529            }
530        } else {
531            light
532        };
533        (&self.out, light)
534    }
535
536    pub fn composed(&self) -> &[Draw] {
537        &self.out
538    }
539
540    pub fn out_capacity(&self) -> usize {
541        self.out.capacity()
542    }
543
544    pub fn pool_capacity(&self) -> (usize, usize) {
545        (self.particles.capacity(), self.rings.capacity())
546    }
547}
548
549fn shaken(mut draw: Draw, camera: Option<Matrix>, motion: f32) -> Draw {
550    if let Some(camera) = camera {
551        draw.transform = multiply(camera, draw.transform);
552    }
553    if draw.fx.blur != [0.0; 2] && motion < 1.0 {
554        draw.fx.blur = draw.fx.blur.map(|value| value * motion);
555    }
556    draw
557}
558
559pub fn camera(offset: ShakeOffset, layout: [f32; 2]) -> Matrix {
560    if offset.is_zero() {
561        return IDENTITY;
562    }
563    let centre = [layout[0] * 0.5, layout[1] * 0.5];
564    multiply(
565        place(
566            [
567                centre[0] + offset.translation[0],
568                centre[1] + offset.translation[1],
569            ],
570            offset.roll,
571        ),
572        super::translation(-centre[0], -centre[1]),
573    )
574}
575
576pub(crate) fn between(rng: &mut Rng, range: [f32; 2]) -> f32 {
577    range[0] + (range[1] - range[0]) * rng.next_f32()
578}