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}