Skip to main content

proof_engine/render/
screen_fx.rs

1//! Transient full-screen effects a game fires at moments: shockwaves, flashes,
2//! and a light-shaft source.
3//!
4//! [`crate::config::RenderConfig`] is the standing look of the picture, the
5//! grade and the bloom and the lens. This is what happens *to* the picture
6//! when something happens in the game. A blow lands and a ring of refraction
7//! runs out from the point of impact. A spell goes off and the frame flashes
8//! its colour and falls back. A brazier or a boss's eye becomes the source
9//! that light shafts stream from.
10//!
11//! All of it is stateful and decays on its own, so a game fires and forgets.
12//! Coordinates are the same screen pixels the UI layer uses: `(0, 0)` at the
13//! top left, `y` down.
14
15use glam::{Vec2, Vec3};
16
17/// Hard cap on simultaneous shockwaves; the composite shader has a fixed
18/// uniform array of this size.
19pub const MAX_SHOCKWAVES: usize = 12;
20
21/// One expanding ring of refraction.
22#[derive(Clone, Copy, Debug)]
23pub struct Shockwave {
24    /// Origin, in screen pixels.
25    pub origin: Vec2,
26    /// Seconds since it was fired.
27    pub age: f32,
28    /// Seconds it lives.
29    pub duration: f32,
30    /// How fast the ring travels, in pixels per second.
31    pub speed: f32,
32    /// Thickness of the ring, in pixels.
33    pub width: f32,
34    /// Peak displacement at the ring, in pixels.
35    pub strength: f32,
36}
37
38impl Shockwave {
39    /// Radius right now, in pixels.
40    pub fn radius(&self) -> f32 {
41        self.age * self.speed
42    }
43
44    /// Displacement right now: the peak, eased out over the lifetime.
45    pub fn current_strength(&self) -> f32 {
46        let t = (self.age / self.duration.max(1e-3)).clamp(0.0, 1.0);
47        let fade = 1.0 - t;
48        self.strength * fade * fade
49    }
50
51    pub fn is_dead(&self) -> bool {
52        self.age >= self.duration
53    }
54}
55
56/// A glossy floor: the picture mirrored about a horizontal line and laid
57/// back over itself below it, fading with distance from the line.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct Reflection {
60    /// The line the picture reflects about, in screen pixels from the top.
61    pub y: f32,
62    /// How much of the mirrored picture shows right at the line. 0.5 is a
63    /// wet floor; 0.15 is polished stone.
64    pub strength: f32,
65    /// How far below the line the reflection is still visible, in pixels.
66    pub fade: f32,
67    /// Horizontal ripple in the reflection, in pixels. 0.0 is a still mirror.
68    pub ripple: f32,
69    /// How blurred the reflection is, in pixels. Rough floors blur more.
70    pub blur: f32,
71}
72
73/// A light in screen space, for the light map.
74///
75/// Pixels within `radius` of it are lit in its colour, falling off with
76/// distance; with `shadows` on, matter between the light and a pixel takes
77/// the light away, so figures are lit on the side that faces it and cast
78/// their shape across the floor behind them.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct ScreenLight {
81    /// Centre, in screen pixels from the top left.
82    pub x: f32,
83    pub y: f32,
84    /// How far it reaches, in pixels.
85    pub radius: f32,
86    /// Linear RGB.
87    pub color: Vec3,
88    /// 1.0 lights its surroundings fully at its centre; 0.3 is a candle.
89    pub intensity: f32,
90    /// Whether matter shadows it.
91    pub shadows: bool,
92}
93
94/// The most lights the light map takes in one frame.
95pub const MAX_LIGHTS: usize = 32;
96
97/// Transient screen effects. Lives on the engine as `engine.fx`.
98#[derive(Clone, Debug)]
99pub struct ScreenFx {
100    /// This frame's lights. Cleared by the engine after every render, so a
101    /// game pushes the lights it wants every frame.
102    pub lights: Vec<ScreenLight>,
103    /// The light every pixel gets with no light on it. White is unlit: the
104    /// scene as drawn. Anything darker turns the light map on.
105    pub ambient: Vec3,
106    /// How much light one fully covered shadow sample removes. Higher is
107    /// harder-edged, denser shadow.
108    pub shadow_density: f32,
109    pub shockwaves: Vec<Shockwave>,
110    /// A floor reflection, if the scene has a floor worth reflecting in.
111    /// Set every frame it should show; cleared with [`clear_reflection`](Self::clear_reflection).
112    pub reflection: Option<Reflection>,
113    /// Heat shimmer over the whole frame, 0.0 to about 1.0. Stronger toward
114    /// the bottom of the frame, where hot air rises from.
115    pub haze: f32,
116    /// Let the renderer pick the light-shaft source itself, from whatever is
117    /// brightest in the bloom, whenever no explicit source is set. Costs one
118    /// tiny read-back per frame and is a frame late, which no one can see.
119    pub auto_shafts: bool,
120    /// Colour added to the whole frame before the tonemap, already scaled by
121    /// its strength. Decays toward black.
122    pub flash: Vec3,
123    /// Fraction of the flash that survives each second.
124    pub flash_decay: f32,
125    /// Where light shafts stream from, in screen pixels, if anywhere.
126    pub shaft_origin: Option<Vec2>,
127    /// How strong the shafts are right now. Eases toward `shaft_target`.
128    pub shaft_strength: f32,
129    /// Where the shaft strength is heading.
130    pub shaft_target: f32,
131    /// How quickly the shafts ease toward the target, per second.
132    pub shaft_ease: f32,
133    /// A colour cast for the shafts. White lets the light's own colour through.
134    pub shaft_tint: Vec3,
135    /// Seconds accumulated, for anything that wants a clock.
136    pub time: f32,
137}
138
139impl Default for ScreenFx {
140    fn default() -> Self {
141        Self {
142            shockwaves: Vec::with_capacity(MAX_SHOCKWAVES),
143            lights: Vec::with_capacity(MAX_LIGHTS),
144            ambient: Vec3::ONE,
145            shadow_density: 5.0,
146            reflection: None,
147            haze: 0.0,
148            auto_shafts: false,
149            flash: Vec3::ZERO,
150            flash_decay: 0.0005,
151            shaft_origin: None,
152            shaft_strength: 0.0,
153            shaft_target: 0.0,
154            shaft_ease: 3.0,
155            shaft_tint: Vec3::ONE,
156            time: 0.0,
157        }
158    }
159}
160
161impl ScreenFx {
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Fire a shockwave with sensible defaults for a hit: a ring that crosses
167    /// a 1280-wide frame in about half a second.
168    ///
169    /// `strength` is the peak displacement in pixels; 6 to 10 reads as a
170    /// blow, 20 or more as an explosion.
171    pub fn shockwave(&mut self, x: f32, y: f32, strength: f32) {
172        self.shockwave_with(Shockwave {
173            origin: Vec2::new(x, y),
174            age: 0.0,
175            duration: 0.55,
176            speed: 1400.0,
177            width: 48.0,
178            strength,
179        });
180    }
181
182    /// Fire a fully specified shockwave. If the cap is reached the oldest
183    /// one is dropped, so the newest blow always shows.
184    pub fn shockwave_with(&mut self, wave: Shockwave) {
185        if self.shockwaves.len() >= MAX_SHOCKWAVES {
186            self.shockwaves.remove(0);
187        }
188        self.shockwaves.push(wave);
189    }
190
191    /// Flash the frame. `strength` of 1.0 is a full-colour hit before the
192    /// tonemap rolls it off; 0.2 is a noticeable pulse.
193    pub fn flash(&mut self, color: Vec3, strength: f32) {
194        let add = color * strength;
195        // The stronger of the two rather than the sum, so a burst of hits
196        // does not stack to a white screen.
197        self.flash = Vec3::new(
198            self.flash.x.max(add.x),
199            self.flash.y.max(add.y),
200            self.flash.z.max(add.z),
201        );
202    }
203
204    /// Stream light shafts from a screen point at the given strength. Call
205    /// every frame the source is on screen; the strength eases in and out.
206    pub fn light_shaft_at(&mut self, x: f32, y: f32, strength: f32) {
207        self.shaft_origin = Some(Vec2::new(x, y));
208        self.shaft_target = strength.max(0.0);
209    }
210
211    /// Let the shafts fade out. The origin is kept until they are gone so the
212    /// fade happens in place.
213    pub fn clear_light_shaft(&mut self) {
214        self.shaft_target = 0.0;
215    }
216
217    /// Add a shadow-casting light for this frame.
218    pub fn light(&mut self, x: f32, y: f32, radius: f32, color: Vec3, intensity: f32) {
219        self.push_light(ScreenLight { x, y, radius, color, intensity, shadows: true });
220    }
221
222    /// Add a light nothing shadows, for a glow rather than a lamp.
223    pub fn light_unshadowed(&mut self, x: f32, y: f32, radius: f32, color: Vec3, intensity: f32) {
224        self.push_light(ScreenLight { x, y, radius, color, intensity, shadows: false });
225    }
226
227    pub fn push_light(&mut self, light: ScreenLight) {
228        if self.lights.len() < MAX_LIGHTS && light.intensity > 0.0 && light.radius > 0.0 {
229            self.lights.push(light);
230        }
231    }
232
233    /// Whether the light map has anything to do this frame.
234    pub fn lighting_active(&self) -> bool {
235        !self.lights.is_empty() || self.ambient != Vec3::ONE
236    }
237
238    /// Pack the lights for the shader: `(u, v, radius_px, shadows)` and
239    /// `(r, g, b, intensity)`, `v` flipped to texture space.
240    pub fn pack_lights(&self, screen_w: f32, screen_h: f32) -> (Vec<f32>, Vec<f32>, usize) {
241        let w = screen_w.max(1.0);
242        let h = screen_h.max(1.0);
243        let mut pos = Vec::with_capacity(MAX_LIGHTS * 4);
244        let mut col = Vec::with_capacity(MAX_LIGHTS * 4);
245        let mut n = 0;
246        for l in self.lights.iter().take(MAX_LIGHTS) {
247            pos.extend_from_slice(&[l.x / w, 1.0 - l.y / h, l.radius, if l.shadows { 1.0 } else { 0.0 }]);
248            col.extend_from_slice(&[l.color.x, l.color.y, l.color.z, l.intensity]);
249            n += 1;
250        }
251        (pos, col, n)
252    }
253
254    /// Reflect the picture about the line `y` pixels from the top, for
255    /// `fade` pixels below it, at the given strength. A still, lightly
256    /// blurred mirror; set the fields on [`Reflection`] for a wet one.
257    pub fn reflect_at(&mut self, y: f32, strength: f32, fade: f32) {
258        self.reflection = Some(Reflection {
259            y,
260            strength: strength.max(0.0),
261            fade: fade.max(1.0),
262            ripple: 0.0,
263            blur: 1.5,
264        });
265    }
266
267    pub fn clear_reflection(&mut self) {
268        self.reflection = None;
269    }
270
271    /// The reflection for the shader: `(line_v, strength, fade_v, ripple_px)`
272    /// and the blur, in texture space with `v = 0` at the bottom.
273    pub fn pack_reflection(&self, screen_w: f32, screen_h: f32) -> ([f32; 4], f32) {
274        let _ = screen_w;
275        match self.reflection {
276            Some(r) if r.strength > 0.0 => {
277                let h = screen_h.max(1.0);
278                ([1.0 - r.y / h, r.strength, r.fade / h, r.ripple], r.blur)
279            }
280            _ => ([0.0, 0.0, 1.0, 0.0], 0.0),
281        }
282    }
283
284    /// Advance every effect by `dt` seconds.
285    pub fn tick(&mut self, dt: f32) {
286        self.time += dt;
287        for w in &mut self.shockwaves {
288            w.age += dt;
289        }
290        self.shockwaves.retain(|w| !w.is_dead());
291
292        // Exponential decay: the fraction that survives one second is
293        // `flash_decay`, so a flash is essentially gone in a third of one.
294        let keep = self.flash_decay.max(1e-6).powf(dt);
295        self.flash *= keep;
296        if self.flash.max_element() < 0.002 {
297            self.flash = Vec3::ZERO;
298        }
299
300        let ease = (self.shaft_ease * dt).clamp(0.0, 1.0);
301        self.shaft_strength += (self.shaft_target - self.shaft_strength) * ease;
302        if self.shaft_target <= 0.0 && self.shaft_strength < 0.005 {
303            self.shaft_strength = 0.0;
304            self.shaft_origin = None;
305        }
306    }
307
308    /// Whether anything at all is active, so the renderer can skip uploads.
309    pub fn is_idle(&self) -> bool {
310        self.shockwaves.is_empty()
311            && self.flash == Vec3::ZERO
312            && self.shaft_strength <= 0.0
313            && self.reflection.is_none()
314            && self.haze <= 0.0
315            && !self.lighting_active()
316    }
317
318    /// Pack the shockwaves for the composite shader.
319    ///
320    /// Returns `(data, strengths, count)`. Each `data` entry is
321    /// `(u, v, radius_px, width_px)` with `u, v` in texture space, which has
322    /// `v = 0` at the bottom, so `y` is flipped here from the UI's `y`-down.
323    pub fn pack_shockwaves(&self, screen_w: f32, screen_h: f32)
324        -> ([[f32; 4]; MAX_SHOCKWAVES], [f32; MAX_SHOCKWAVES], usize)
325    {
326        let mut data = [[0.0f32; 4]; MAX_SHOCKWAVES];
327        let mut strength = [0.0f32; MAX_SHOCKWAVES];
328        let w = screen_w.max(1.0);
329        let h = screen_h.max(1.0);
330        let mut n = 0;
331        for wave in self.shockwaves.iter().take(MAX_SHOCKWAVES) {
332            let s = wave.current_strength();
333            if s <= 0.01 {
334                continue;
335            }
336            data[n] = [
337                wave.origin.x / w,
338                1.0 - wave.origin.y / h,
339                wave.radius(),
340                wave.width.max(1.0),
341            ];
342            strength[n] = s;
343            n += 1;
344        }
345        (data, strength, n)
346    }
347
348    /// The light-shaft source for the shader: `(u, v, strength)`, or zero
349    /// strength when there is none.
350    pub fn pack_shaft(&self, screen_w: f32, screen_h: f32) -> [f32; 3] {
351        match self.shaft_origin {
352            Some(o) if self.shaft_strength > 0.0 => [
353                o.x / screen_w.max(1.0),
354                1.0 - o.y / screen_h.max(1.0),
355                self.shaft_strength,
356            ],
357            _ => [0.5, 0.5, 0.0],
358        }
359    }
360}