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}