Skip to main content

egui_screensaver_flame/
lib.rs

1//! Flame screensaver for [egui](https://github.com/emilk/egui).
2//!
3//! A windswept fire rendered from a fractal-noise fragment shader, ported from
4//! [Blake Bowen's "Flame in the wind" CodePen](https://codepen.io/osublake/pen/pqNXoq),
5//! itself adapting a shader by Shadertoy user
6//! [@kuvkar](https://www.shadertoy.com/view/4tXXRn).
7//!
8//! Like the original, this runs the shader on the GPU: a fullscreen triangle
9//! is drawn through an [`egui::PaintCallback`] using [`egui_glow`], so the
10//! per-pixel math runs in parallel across the GPU's shader cores instead of
11//! serially on the CPU. **This requires the host `eframe::App` to use the
12//! `glow` rendering backend** (not `wgpu`) — [`FlameBackground::paint`]
13//! simply skips drawing if no `glow::Context` is available.
14//!
15//! Rendering is capped at 30 FPS. If the hardware cannot sustain that rate,
16//! frames are painted as fast as possible without any artificial delay.
17//!
18//! # Usage
19//!
20//! ```rust,no_run
21//! use egui_screensaver_flame::FlameBackground;
22//!
23//! struct MyApp {
24//!     flame: FlameBackground,
25//! }
26//!
27//! impl eframe::App for MyApp {
28//!     fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
29//!         let ctx = ui.ctx().clone();
30//!         // Call paint once per frame before drawing any UI windows so the
31//!         // screensaver sits on the background layer behind everything else.
32//!         self.flame.paint(&ctx, frame.gl());
33//!     }
34//! }
35//! ```
36
37use std::sync::Arc;
38use std::time::Duration;
39
40use egui::{Color32, Context, LayerId, Painter, Shape};
41use egui_glow::{CallbackFn, ShaderVersion};
42use glow::HasContext as _;
43
44/// Side length of the tileable value-noise texture used for the fractal
45/// Brownian motion, in texels.
46const NOISE_SIZE: usize = 64;
47
48/// Matches the original's `time += 0.1 * delta` running on a ~60 fps Pixi
49/// ticker (`delta` ~= 1 at 60 fps), i.e. roughly 6 time-units per second.
50const TIME_SPEED: f32 = 6.0;
51
52const REPAINT_MS: u64 = 33;
53
54const VERTEX_SRC: &str = include_str!("shader/vertex.glsl");
55const FRAGMENT_SRC: &str = include_str!("shader/fragment.glsl");
56
57/// A tileable value-noise texture uploaded once to the GPU, standing in for
58/// the original's noise PNG. Small xorshift64 PRNG so this crate needs no
59/// extra dependency for it.
60fn generate_noise_texels(size: usize) -> Vec<u8> {
61    let mut state = 0x9E3779B97F4A7C15u64;
62    (0..size * size)
63        .map(|_| {
64            state ^= state << 13;
65            state ^= state >> 7;
66            state ^= state << 17;
67            (state >> 56) as u8
68        })
69        .collect()
70}
71
72unsafe fn compile_shader(
73    gl: &glow::Context,
74    shader_type: u32,
75    source: &str,
76) -> Result<glow::Shader, String> {
77    unsafe {
78        let shader = gl.create_shader(shader_type)?;
79        gl.shader_source(shader, source);
80        gl.compile_shader(shader);
81        if gl.get_shader_compile_status(shader) {
82            Ok(shader)
83        } else {
84            Err(gl.get_shader_info_log(shader))
85        }
86    }
87}
88
89unsafe fn link_program(
90    gl: &glow::Context,
91    shaders: &[glow::Shader],
92) -> Result<glow::Program, String> {
93    unsafe {
94        let program = gl.create_program()?;
95        for &shader in shaders {
96            gl.attach_shader(program, shader);
97        }
98        gl.link_program(program);
99        let result = if gl.get_program_link_status(program) {
100            Ok(program)
101        } else {
102            Err(gl.get_program_info_log(program))
103        };
104        for &shader in shaders {
105            gl.detach_shader(program, shader);
106            gl.delete_shader(shader);
107        }
108        result
109    }
110}
111
112/// Compiled GPU resources for the flame shader: program, fullscreen-triangle
113/// VBO/VAO, and the one-time noise texture. Built once, lazily, on the first
114/// frame a `glow::Context` is available (see [`FlameBackground::paint`]).
115/// Nothing here mutates after construction, so this is shared across frames
116/// as a plain `Arc` with no interior mutability needed.
117struct GlResources {
118    gl: Arc<glow::Context>,
119    program: glow::Program,
120    vao: glow::VertexArray,
121    vbo: glow::Buffer,
122    noise_texture: glow::Texture,
123    u_time: Option<glow::UniformLocation>,
124    u_dimensions: Option<glow::UniformLocation>,
125    u_scale: Option<glow::UniformLocation>,
126}
127
128impl GlResources {
129    fn new(gl: Arc<glow::Context>) -> Result<Self, String> {
130        unsafe {
131            let shader_version = ShaderVersion::get(&gl);
132            let new_interface = shader_version.is_new_shader_interface() as i32;
133            let header = format!(
134                "{}\n#define NEW_SHADER_INTERFACE {new_interface}\n",
135                shader_version.version_declaration()
136            );
137
138            let vertex =
139                compile_shader(&gl, glow::VERTEX_SHADER, &format!("{header}{VERTEX_SRC}"))?;
140            let fragment = compile_shader(
141                &gl,
142                glow::FRAGMENT_SHADER,
143                &format!("{header}{FRAGMENT_SRC}"),
144            )?;
145            let program = link_program(&gl, &[vertex, fragment])?;
146
147            // A single triangle covering the whole screen (see vertex.glsl).
148            let vertices: [f32; 6] = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0];
149            let vbo = gl.create_buffer()?;
150            gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
151            gl.buffer_data_u8_slice(
152                glow::ARRAY_BUFFER,
153                f32_slice_as_bytes(&vertices),
154                glow::STATIC_DRAW,
155            );
156
157            let vao = gl.create_vertex_array()?;
158            gl.bind_vertex_array(Some(vao));
159            let a_pos = gl.get_attrib_location(program, "a_pos");
160            if let Some(a_pos) = a_pos {
161                gl.enable_vertex_attrib_array(a_pos);
162                gl.vertex_attrib_pointer_f32(a_pos, 2, glow::FLOAT, false, 0, 0);
163            }
164            gl.bind_vertex_array(None);
165            gl.bind_buffer(glow::ARRAY_BUFFER, None);
166
167            // Single-channel noise value replicated across RGBA8, which is
168            // supported everywhere (unlike `LUMINANCE`, deprecated in
169            // desktop core-profile GL, or `RED`/`R8`, unavailable on
170            // WebGL1/GLES2) — the shader only reads the `.r` channel.
171            let texels = generate_noise_texels(NOISE_SIZE);
172            let mut rgba = Vec::with_capacity(texels.len() * 4);
173            for t in texels {
174                rgba.extend_from_slice(&[t, t, t, t]);
175            }
176            let noise_texture = gl.create_texture()?;
177            gl.bind_texture(glow::TEXTURE_2D, Some(noise_texture));
178            gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
179            gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
180            gl.tex_parameter_i32(
181                glow::TEXTURE_2D,
182                glow::TEXTURE_MIN_FILTER,
183                glow::LINEAR as i32,
184            );
185            gl.tex_parameter_i32(
186                glow::TEXTURE_2D,
187                glow::TEXTURE_MAG_FILTER,
188                glow::LINEAR as i32,
189            );
190            gl.tex_image_2d(
191                glow::TEXTURE_2D,
192                0,
193                glow::RGBA as i32,
194                NOISE_SIZE as i32,
195                NOISE_SIZE as i32,
196                0,
197                glow::RGBA,
198                glow::UNSIGNED_BYTE,
199                glow::PixelUnpackData::Slice(Some(&rgba)),
200            );
201            gl.bind_texture(glow::TEXTURE_2D, None);
202
203            gl.use_program(Some(program));
204            if let Some(loc) = gl.get_uniform_location(program, "mapSampler") {
205                gl.uniform_1_i32(Some(&loc), 0);
206            }
207            let u_time = gl.get_uniform_location(program, "time");
208            let u_dimensions = gl.get_uniform_location(program, "dimensions");
209            let u_scale = gl.get_uniform_location(program, "scale");
210            gl.use_program(None);
211
212            Ok(Self {
213                gl,
214                program,
215                vao,
216                vbo,
217                noise_texture,
218                u_time,
219                u_dimensions,
220                u_scale,
221            })
222        }
223    }
224
225    fn draw(&self, time: f32, scale: f32, screen_size_px: [u32; 2]) {
226        unsafe {
227            let gl = &self.gl;
228            gl.use_program(Some(self.program));
229            gl.active_texture(glow::TEXTURE0);
230            gl.bind_texture(glow::TEXTURE_2D, Some(self.noise_texture));
231            gl.uniform_1_f32(self.u_time.as_ref(), time);
232            gl.uniform_1_f32(self.u_scale.as_ref(), scale);
233            gl.uniform_2_f32(
234                self.u_dimensions.as_ref(),
235                screen_size_px[0] as f32,
236                screen_size_px[1] as f32,
237            );
238            gl.bind_vertex_array(Some(self.vao));
239            gl.enable(glow::BLEND);
240            gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA);
241            gl.draw_arrays(glow::TRIANGLES, 0, 3);
242            gl.bind_vertex_array(None);
243        }
244    }
245}
246
247impl Drop for GlResources {
248    fn drop(&mut self) {
249        unsafe {
250            self.gl.delete_program(self.program);
251            self.gl.delete_vertex_array(self.vao);
252            self.gl.delete_buffer(self.vbo);
253            self.gl.delete_texture(self.noise_texture);
254        }
255    }
256}
257
258fn f32_slice_as_bytes(floats: &[f32]) -> &[u8] {
259    // SAFETY: `f32` has no padding/invalid bit patterns, and the resulting
260    // slice's lifetime and length are derived directly from `floats`.
261    unsafe {
262        std::slice::from_raw_parts(floats.as_ptr().cast::<u8>(), std::mem::size_of_val(floats))
263    }
264}
265
266/// Flame screensaver state.
267///
268/// Create one instance (e.g. as a field of your `eframe::App` struct) and
269/// call [`FlameBackground::paint`] every frame from your `ui` method.
270pub struct FlameBackground {
271    time: f32,
272    last_time: Option<f64>,
273    gl_resources: Option<Arc<GlResources>>,
274    /// Widens the flame's silhouette horizontally. `1.0` is the original
275    /// width; larger values make it wider, smaller values narrower. Height,
276    /// turbulence pattern, and the background halo are unaffected, so the
277    /// flame doesn't grow taller or get cut off at the top. Must be
278    /// positive (`0.0` would divide by zero in the shader).
279    pub scale: f32,
280    /// Multiplies the animation rate. `1.0` is the original speed, `2.0`
281    /// doubles it, `0.5` halves it, `0.0` freezes the animation.
282    pub speed: f32,
283}
284
285impl Default for FlameBackground {
286    fn default() -> Self {
287        Self {
288            time: 0.0,
289            last_time: None,
290            gl_resources: None,
291            scale: 1.0,
292            speed: 1.0,
293        }
294    }
295}
296
297impl FlameBackground {
298    /// Paint the screensaver onto the egui background layer for this frame.
299    ///
300    /// Call this once per frame **before** drawing any UI panels or windows
301    /// so the animation appears behind all other content. `gl` should come
302    /// from `eframe::Frame::gl()` — if it's `None` (e.g. the host app uses
303    /// the `wgpu` backend instead of `glow`), this draws nothing but a black
304    /// background.
305    pub fn paint(&mut self, ctx: &Context, gl: Option<&Arc<glow::Context>>) {
306        ctx.request_repaint_after(Duration::from_millis(REPAINT_MS));
307
308        let now = ctx.input(|i| i.time);
309        let dt = match self.last_time {
310            Some(last) => (now - last).clamp(0.0, 0.1) as f32,
311            None => 0.0,
312        };
313        self.last_time = Some(now);
314        self.time += dt * TIME_SPEED * self.speed;
315
316        if self.gl_resources.is_none()
317            && let Some(gl) = gl
318        {
319            match GlResources::new(gl.clone()) {
320                // On wasm32, `GlResources` (via `glow::Context`) isn't `Sync`
321                // — see `AssertSendSync` below, applied where this `Arc` is
322                // captured into the `Fn + Sync + Send` paint callback.
323                #[allow(clippy::arc_with_non_send_sync)]
324                Ok(resources) => self.gl_resources = Some(Arc::new(resources)),
325                Err(err) => log::error!("egui-screensaver-flame: GL setup failed: {err}"),
326            }
327        }
328
329        let screen_rect = ctx.viewport_rect();
330        let painter = Painter::new(ctx.clone(), LayerId::background(), screen_rect);
331        painter.rect_filled(screen_rect, 0.0, Color32::BLACK);
332
333        if let Some(resources) = self.gl_resources.clone() {
334            let time = self.time;
335            let scale = self.scale;
336            let resources = AssertSendSync(resources);
337            painter.add(Shape::Callback(egui::PaintCallback {
338                rect: screen_rect,
339                callback: Arc::new(CallbackFn::new(move |info, _painter| {
340                    resources.get().draw(time, scale, info.screen_size_px);
341                })),
342            }));
343        }
344    }
345}
346
347/// `CallbackFn` requires `Fn + Sync + Send` on every target, but on wasm32
348/// (WebGL) `glow::Context` isn't `Sync` — it wraps `web_sys` handles behind
349/// `RefCell`s, since JS objects aren't natively thread-safe. `wasm32`
350/// without the `atomics` target feature (which eframe doesn't enable) never
351/// actually runs on more than one thread, so this assertion is honest: the
352/// wrapped value never crosses a real thread boundary on any target.
353struct AssertSendSync<T>(T);
354unsafe impl<T> Send for AssertSendSync<T> {}
355unsafe impl<T> Sync for AssertSendSync<T> {}
356
357impl<T> AssertSendSync<T> {
358    /// A method (rather than direct `.0` field access) so closures capture
359    /// this wrapper as a whole — Rust's disjoint closure captures would
360    /// otherwise capture just the inner `T` from a bare `resources.0`,
361    /// silently defeating the wrapper.
362    fn get(&self) -> &T {
363        &self.0
364    }
365}