Skip to main content

proof_engine/render/postfx/
pipeline.rs

1//! GPU post-processing pipeline.
2//!
3//! ```text
4//! scene FBO (RGBA16F ×2: colour, emission) at render scale
5//!   ├─ emission ─► bloom pyramid: threshold, blur down, tent-filter up
6//!   └─ colour ───┐
7//!                ├─ composite: refraction, lens, exposure, indirect light,
8//!                │             bloom, halation, shafts, flare, flash,
9//!                │             tonemap, grade, vignette, grain, dither
10//!                │      ├─ (fxaa off) ─► screen
11//!                │      └─ (fxaa on)  ─► LDR FBO ─► FXAA ─► screen
12//! ```
13//!
14//! The scene targets are half-float. Before this they were eight bits per
15//! channel, which meant a scene lit by a few hundred thousand overlapping
16//! emissive particles was clipped to white before the composite ever saw it,
17//! and the exposure, tonemap and halation were working on a picture that had
18//! already lost its highlights. Now the composite is the first place the
19//! range is brought down.
20
21use std::cell::Cell;
22use glow::HasContext;
23use crate::render::shaders::{FULLSCREEN_VERT, BLOOM_FRAG, COMPOSITE_FRAG, UPSAMPLE_FRAG, FXAA_FRAG, LIGHT_FRAG, PERSIST_FRAG};
24use crate::render::screen_fx::{ScreenFx, MAX_SHOCKWAVES};
25use crate::config::RenderConfig;
26
27pub struct PostFxPipeline {
28    // Scene FBO — dual HDR colour attachments, at render scale
29    pub scene_fbo:          glow::Framebuffer,
30    pub scene_color_tex:    glow::Texture,    // attachment 0: rendered scene
31    pub scene_emission_tex: glow::Texture,    // attachment 1: emission → bloom input
32    pub scene_occluder_tex: glow::Texture,    // attachment 2: matter coverage → shadows
33    /// The scene targets' size. `render_scale` times the window, so a 4K
34    /// display can render at 1440p and be upsampled by the composite.
35    scene_w: u32,
36    scene_h: u32,
37
38    // Bloom scratch (half-res)
39    /// Horizontal-pass scratch, one per pyramid level. A single shared
40    /// scratch sized for level 0 was wrong: each smaller level wrote its
41    /// bottom-left corner and the vertical pass then read the whole
42    /// texture, so three quarters of every level was the clear colour.
43    bloom_fbo: Vec<glow::Framebuffer>,
44    bloom_tex: Vec<glow::Texture>,
45
46    /// A pyramid of successively halved targets, and their sizes.
47    ///
48    /// Real bloom is scale-invariant: a bright point puts light into a
49    /// tight core, a wider halo, and a very broad veil across the frame. A
50    /// pyramid gets all three for about a third of one full-resolution pass.
51    mip_fbo: Vec<glow::Framebuffer>,
52    mip_tex: Vec<glow::Texture>,
53    mip_size: Vec<(i32, i32)>,
54    /// Second chain, for the upward pass that combines them.
55    up_fbo: Vec<glow::Framebuffer>,
56    up_tex: Vec<glow::Texture>,
57    upsample_prog: glow::Program,
58
59    /// The light map, at half the scene size.
60    light_fbo: glow::Framebuffer,
61    light_tex: glow::Texture,
62    light_size: (i32, i32),
63    light_prog: glow::Program,
64
65    /// Motion trails: last frame's scene, two of them so one can be read
66    /// while the other is written.
67    history_fbo: [glow::Framebuffer; 2],
68    history_tex: [glow::Texture; 2],
69    history_idx: usize,
70    history_valid: bool,
71    persist_prog: glow::Program,
72
73    /// Where the composite lands when anti-aliasing runs after it.
74    ldr_fbo: glow::Framebuffer,
75    ldr_tex: glow::Texture,
76    fxaa_prog: glow::Program,
77
78    bloom_prog:     glow::Program,
79    composite_prog: glow::Program,
80
81    /// The brightest texel of the smallest bloom level last frame, as
82    /// `(u, v, strength, unused)` plus its colour: the automatic light-shaft
83    /// source. Read back from the GPU, so it is a frame late.
84    auto_shaft: Cell<([f32; 4], [f32; 3])>,
85    /// Scratch for that read-back, sized to the smallest level.
86    readback: std::cell::RefCell<Vec<f32>>,
87
88    // Empty VAO for fullscreen draws (GL 3.3 Core requires a VAO bound)
89    fullscreen_vao: glow::VertexArray,
90}
91
92/// Scene target size for a window size and render scale, never below one
93/// pixel.
94fn scaled(width: u32, height: u32, scale: f32) -> (u32, u32) {
95    let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
96    (
97        ((width as f32 * s).round() as u32).max(1),
98        ((height as f32 * s).round() as u32).max(1),
99    )
100}
101
102impl PostFxPipeline {
103    pub unsafe fn new(gl: &glow::Context, width: u32, height: u32, render_scale: f32) -> Self {
104        let bloom_prog     = compile_postfx_program(gl, FULLSCREEN_VERT, BLOOM_FRAG);
105        let composite_prog = compile_postfx_program(gl, FULLSCREEN_VERT, COMPOSITE_FRAG);
106        let upsample_prog  = compile_postfx_program(gl, FULLSCREEN_VERT, UPSAMPLE_FRAG);
107        let fxaa_prog      = compile_postfx_program(gl, FULLSCREEN_VERT, FXAA_FRAG);
108        let light_prog     = compile_postfx_program(gl, FULLSCREEN_VERT, LIGHT_FRAG);
109        let persist_prog   = compile_postfx_program(gl, FULLSCREEN_VERT, PERSIST_FRAG);
110        let fullscreen_vao = gl.create_vertex_array().expect("postfx fullscreen_vao");
111
112        // Pre-bind sampler units (never changes)
113        gl.use_program(Some(bloom_prog));
114        set_u_i32(gl, bloom_prog, "u_texture", 0);
115
116        gl.use_program(Some(composite_prog));
117        set_u_i32(gl, composite_prog, "u_scene", 0);
118        set_u_i32(gl, composite_prog, "u_bloom", 1);
119        set_u_i32(gl, composite_prog, "u_light", 2);
120        set_u_i32(gl, composite_prog, "u_emission", 3);
121
122        gl.use_program(Some(light_prog));
123        set_u_i32(gl, light_prog, "u_occluder", 0);
124
125        gl.use_program(Some(persist_prog));
126        set_u_i32(gl, persist_prog, "u_scene", 0);
127        set_u_i32(gl, persist_prog, "u_history", 1);
128
129        gl.use_program(Some(upsample_prog));
130        set_u_i32(gl, upsample_prog, "u_lower", 0);
131        set_u_i32(gl, upsample_prog, "u_higher", 1);
132
133        gl.use_program(Some(fxaa_prog));
134        set_u_i32(gl, fxaa_prog, "u_image", 0);
135
136        let (scene_w, scene_h) = scaled(width, height, render_scale);
137        let (scene_fbo, scene_color_tex, scene_emission_tex, scene_occluder_tex) =
138            create_scene_fbo(gl, scene_w, scene_h);
139        let (bloom_fbo, bloom_tex, _) = create_pyramid(gl, scene_w, scene_h);
140        let (mip_fbo, mip_tex, mip_size) = create_pyramid(gl, scene_w, scene_h);
141        let (up_fbo, up_tex, _) = create_pyramid(gl, scene_w, scene_h);
142        let (ldr_fbo, ldr_tex) = create_ldr_fbo(gl, width, height);
143        let (lw, lh) = ((scene_w / 2).max(1), (scene_h / 2).max(1));
144        let (light_fbo, light_tex) = create_hdr_fbo(gl, lw, lh, "light");
145        let (h0, t0) = create_hdr_fbo(gl, scene_w, scene_h, "history 0");
146        let (h1, t1) = create_hdr_fbo(gl, scene_w, scene_h, "history 1");
147
148        Self {
149            scene_fbo,
150            scene_color_tex,
151            scene_emission_tex,
152            scene_occluder_tex,
153            scene_w,
154            scene_h,
155            light_fbo,
156            light_tex,
157            light_size: (lw as i32, lh as i32),
158            light_prog,
159            history_fbo: [h0, h1],
160            history_tex: [t0, t1],
161            history_idx: 0,
162            history_valid: false,
163            persist_prog,
164            bloom_fbo,
165            bloom_tex,
166            mip_fbo,
167            mip_tex,
168            mip_size,
169            up_fbo,
170            up_tex,
171            upsample_prog,
172            ldr_fbo,
173            ldr_tex,
174            fxaa_prog,
175            bloom_prog,
176            composite_prog,
177            auto_shaft: Cell::new(([0.5, 0.5, 0.0, 0.0], [1.0, 1.0, 1.0])),
178            readback: std::cell::RefCell::new(Vec::new()),
179            fullscreen_vao,
180        }
181    }
182
183    /// Find the brightest texel of the smallest bloom level. The level is
184    /// about twenty texels across, so the read-back is a few kilobytes.
185    unsafe fn read_auto_shaft(&self, gl: &glow::Context) {
186        let Some(top) = self.mip_fbo.len().checked_sub(1) else { return };
187        let (w, h) = self.mip_size[top];
188        let n = (w * h * 4) as usize;
189        let mut buf = self.readback.borrow_mut();
190        if buf.len() != n {
191            buf.resize(n, 0.0);
192        }
193        gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.mip_fbo[top]));
194        gl.read_buffer(glow::COLOR_ATTACHMENT0);
195        gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
196        gl.read_pixels(
197            0, 0, w, h,
198            glow::RGBA, glow::FLOAT,
199            glow::PixelPackData::Slice(Some(bytemuck::cast_slice_mut(&mut buf[..]))),
200        );
201        let mut best = 0.0f32;
202        let mut best_i = 0usize;
203        for i in 0..(w * h) as usize {
204            let r = buf[i * 4];
205            let g = buf[i * 4 + 1];
206            let b = buf[i * 4 + 2];
207            let lum = r * 0.2126 + g * 0.7152 + b * 0.0722;
208            if lum > best {
209                best = lum;
210                best_i = i;
211            }
212        }
213        if best <= 1e-4 {
214            self.auto_shaft.set(([0.5, 0.5, 0.0, 0.0], [1.0, 1.0, 1.0]));
215            return;
216        }
217        let x = (best_i as i32 % w) as f32 + 0.5;
218        let y = (best_i as i32 / w) as f32 + 0.5;
219        let r = buf[best_i * 4];
220        let g = buf[best_i * 4 + 1];
221        let b = buf[best_i * 4 + 2];
222        let m = r.max(g).max(b).max(1e-4);
223        // The smallest level is the whole frame's light averaged over a few
224        // texels, so even a bright brazier reads as a fraction there.
225        let strength = (best * 6.0).clamp(0.0, 1.0);
226        self.auto_shaft.set(([x / w as f32, y / h as f32, strength, 0.0], [r / m, g / m, b / m]));
227    }
228
229    /// The size of the scene targets, which is what the scene passes must
230    /// set their viewport to.
231    pub fn scene_size(&self) -> (u32, u32) {
232        (self.scene_w, self.scene_h)
233    }
234
235    /// Recreate every target for a new window size or render scale.
236    pub unsafe fn resize(&mut self, gl: &glow::Context, width: u32, height: u32, render_scale: f32) {
237        gl.delete_framebuffer(self.scene_fbo);
238        gl.delete_texture(self.scene_color_tex);
239        gl.delete_texture(self.scene_emission_tex);
240        gl.delete_texture(self.scene_occluder_tex);
241        gl.delete_framebuffer(self.light_fbo);
242        gl.delete_texture(self.light_tex);
243        for i in 0..2 {
244            gl.delete_framebuffer(self.history_fbo[i]);
245            gl.delete_texture(self.history_tex[i]);
246        }
247        for f in self.bloom_fbo.drain(..) {
248            gl.delete_framebuffer(f);
249        }
250        for t in self.bloom_tex.drain(..) {
251            gl.delete_texture(t);
252        }
253        for f in self.mip_fbo.drain(..) {
254            gl.delete_framebuffer(f);
255        }
256        for t in self.mip_tex.drain(..) {
257            gl.delete_texture(t);
258        }
259        for f in self.up_fbo.drain(..) {
260            gl.delete_framebuffer(f);
261        }
262        for t in self.up_tex.drain(..) {
263            gl.delete_texture(t);
264        }
265        gl.delete_framebuffer(self.ldr_fbo);
266        gl.delete_texture(self.ldr_tex);
267
268        let (scene_w, scene_h) = scaled(width, height, render_scale);
269        self.scene_w = scene_w;
270        self.scene_h = scene_h;
271
272        let (mip_fbo, mip_tex, mip_size) = create_pyramid(gl, scene_w, scene_h);
273        let (up_fbo, up_tex, _) = create_pyramid(gl, scene_w, scene_h);
274        self.mip_fbo = mip_fbo;
275        self.mip_tex = mip_tex;
276        self.mip_size = mip_size;
277        self.up_fbo = up_fbo;
278        self.up_tex = up_tex;
279
280        let (scene_fbo, scene_color_tex, scene_emission_tex, scene_occluder_tex) =
281            create_scene_fbo(gl, scene_w, scene_h);
282        let (bloom_fbo, bloom_tex, _) = create_pyramid(gl, scene_w, scene_h);
283        let (ldr_fbo, ldr_tex) = create_ldr_fbo(gl, width, height);
284        let (lw, lh) = ((scene_w / 2).max(1), (scene_h / 2).max(1));
285        let (light_fbo, light_tex) = create_hdr_fbo(gl, lw, lh, "light");
286        let (h0, t0) = create_hdr_fbo(gl, scene_w, scene_h, "history 0");
287        let (h1, t1) = create_hdr_fbo(gl, scene_w, scene_h, "history 1");
288        self.light_fbo = light_fbo;
289        self.light_tex = light_tex;
290        self.light_size = (lw as i32, lh as i32);
291        self.history_fbo = [h0, h1];
292        self.history_tex = [t0, t1];
293        self.history_valid = false;
294
295        self.scene_fbo          = scene_fbo;
296        self.scene_color_tex    = scene_color_tex;
297        self.scene_emission_tex = scene_emission_tex;
298        self.scene_occluder_tex = scene_occluder_tex;
299        self.bloom_fbo          = bloom_fbo;
300        self.bloom_tex          = bloom_tex;
301        self.ldr_fbo            = ldr_fbo;
302        self.ldr_tex            = ldr_tex;
303    }
304
305    /// Run bloom, composite, and anti-aliasing, ending on the default
306    /// (screen) framebuffer. Returns how many draws it made.
307    pub unsafe fn run(
308        &mut self,
309        gl:     &glow::Context,
310        config: &RenderConfig,
311        fx:     &ScreenFx,
312        full_w: u32,
313        full_h: u32,
314        time:   f32,
315    ) -> u32 {
316        let mut draws = 0u32;
317        gl.bind_vertex_array(Some(self.fullscreen_vao));
318        gl.disable(glow::BLEND);
319
320        // ── Persistence ──────────────────────────────────────────────────────
321        //
322        // The scene the composite reads is either the raw one or the one
323        // with last frame's trails folded in.
324        let mut scene_tex = self.scene_color_tex;
325        if config.persistence > 0.0 {
326            let next = self.history_idx ^ 1;
327            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.history_fbo[next]));
328            gl.viewport(0, 0, self.scene_w as i32, self.scene_h as i32);
329            gl.use_program(Some(self.persist_prog));
330            gl.active_texture(glow::TEXTURE0);
331            gl.bind_texture(glow::TEXTURE_2D, Some(self.scene_color_tex));
332            gl.active_texture(glow::TEXTURE1);
333            gl.bind_texture(glow::TEXTURE_2D, Some(self.history_tex[self.history_idx]));
334            let keep = if self.history_valid { config.persistence } else { 0.0 };
335            set_u_f32(gl, self.persist_prog, "u_persistence", keep);
336            gl.draw_arrays(glow::TRIANGLES, 0, 3);
337            gl.active_texture(glow::TEXTURE0);
338            self.history_idx = next;
339            self.history_valid = true;
340            scene_tex = self.history_tex[next];
341            draws += 1;
342        } else {
343            self.history_valid = false;
344        }
345
346        // ── The light map ────────────────────────────────────────────────────
347        let lighting = fx.lighting_active();
348        if lighting {
349            let (lw, lh) = self.light_size;
350            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.light_fbo));
351            gl.viewport(0, 0, lw, lh);
352            gl.use_program(Some(self.light_prog));
353            gl.active_texture(glow::TEXTURE0);
354            gl.bind_texture(glow::TEXTURE_2D, Some(self.scene_occluder_tex));
355            let p = self.light_prog;
356            set_u_vec2(gl, p, "u_screen", [full_w as f32, full_h as f32]);
357            set_u_vec3(gl, p, "u_ambient", fx.ambient.to_array());
358            set_u_f32(gl, p, "u_shadow_density", fx.shadow_density.max(0.0));
359            let (pos, col, n) = fx.pack_lights(full_w as f32, full_h as f32);
360            set_u_i32(gl, p, "u_count", n as i32);
361            if n > 0 {
362                if let Some(loc) = gl.get_uniform_location(p, "u_light_pos[0]") {
363                    gl.uniform_4_f32_slice(Some(&loc), &pos);
364                }
365                if let Some(loc) = gl.get_uniform_location(p, "u_light_color[0]") {
366                    gl.uniform_4_f32_slice(Some(&loc), &col);
367                }
368            }
369            gl.draw_arrays(glow::TRIANGLES, 0, 3);
370            draws += 1;
371        }
372
373        // ── Bloom ────────────────────────────────────────────────────────────
374        //
375        // Down the pyramid blurring as it goes, then back up adding each level
376        // into the one above it. The result contains a tight core, a wide
377        // halo and a broad veil at once.
378        if config.bloom_enabled && !self.mip_fbo.is_empty() {
379            let radius = config.bloom_radius.max(0.5);
380            // Nothing in the bloom chain may inherit the scene ground colour.
381            gl.clear_color(0.0, 0.0, 0.0, 0.0);
382            gl.use_program(Some(self.bloom_prog));
383            gl.active_texture(glow::TEXTURE0);
384            set_u_f32(gl, self.bloom_prog, "u_threshold", config.bloom_threshold);
385            set_u_f32(gl, self.bloom_prog, "u_knee", config.bloom_knee.max(1e-3));
386            set_u_f32(gl, self.bloom_prog, "u_stretch", 1.0);
387
388            // ── Down ─────────────────────────────────────────────────────────
389            for level in 0..self.mip_fbo.len() {
390                let (w, h) = self.mip_size[level];
391                let source = if level == 0 {
392                    self.scene_emission_tex
393                } else {
394                    self.mip_tex[level - 1]
395                };
396
397                gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.bloom_fbo[level]));
398                gl.viewport(0, 0, w, h);
399                gl.clear(glow::COLOR_BUFFER_BIT);
400                gl.bind_texture(glow::TEXTURE_2D, Some(source));
401                set_u_bool(gl, self.bloom_prog, "u_prefilter", level == 0);
402                set_u_bool(gl, self.bloom_prog, "u_horizontal", true);
403                set_u_f32(gl,  self.bloom_prog, "u_radius", radius);
404                gl.draw_arrays(glow::TRIANGLES, 0, 3);
405
406                gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.mip_fbo[level]));
407                gl.viewport(0, 0, w, h);
408                gl.clear(glow::COLOR_BUFFER_BIT);
409                gl.bind_texture(glow::TEXTURE_2D, Some(self.bloom_tex[level]));
410                set_u_bool(gl, self.bloom_prog, "u_prefilter", false);
411                set_u_bool(gl, self.bloom_prog, "u_horizontal", false);
412                gl.draw_arrays(glow::TRIANGLES, 0, 3);
413                draws += 2;
414            }
415
416            // ── The streak ───────────────────────────────────────────────────
417            if config.anamorphic > 0.0 && self.mip_fbo.len() > 2 {
418                let level = self.mip_fbo.len() / 2;
419                let (w, h) = self.mip_size[level];
420                gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.bloom_fbo[level]));
421                gl.viewport(0, 0, w, h);
422                gl.clear(glow::COLOR_BUFFER_BIT);
423                gl.bind_texture(glow::TEXTURE_2D, Some(self.mip_tex[level]));
424                set_u_bool(gl, self.bloom_prog, "u_horizontal", true);
425                set_u_f32(gl,  self.bloom_prog, "u_stretch", 9.0 * config.anamorphic);
426                gl.draw_arrays(glow::TRIANGLES, 0, 3);
427
428                gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.mip_fbo[level]));
429                gl.viewport(0, 0, w, h);
430                gl.clear(glow::COLOR_BUFFER_BIT);
431                gl.bind_texture(glow::TEXTURE_2D, Some(self.bloom_tex[level]));
432                set_u_bool(gl, self.bloom_prog, "u_horizontal", true);
433                set_u_f32(gl,  self.bloom_prog, "u_stretch", 16.0 * config.anamorphic);
434                gl.draw_arrays(glow::TRIANGLES, 0, 3);
435                set_u_f32(gl,  self.bloom_prog, "u_stretch", 1.0);
436                draws += 2;
437            }
438
439            // ── Up ───────────────────────────────────────────────────────────
440            gl.use_program(Some(self.upsample_prog));
441            let top = self.mip_fbo.len() - 1;
442            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.up_fbo[top]));
443            let (w, h) = self.mip_size[top];
444            gl.viewport(0, 0, w, h);
445            gl.clear(glow::COLOR_BUFFER_BIT);
446            gl.active_texture(glow::TEXTURE0);
447            gl.bind_texture(glow::TEXTURE_2D, Some(self.mip_tex[top]));
448            gl.active_texture(glow::TEXTURE1);
449            gl.bind_texture(glow::TEXTURE_2D, Some(self.mip_tex[top]));
450            set_u_f32(gl, self.upsample_prog, "u_radius", 1.0);
451            set_u_f32(gl, self.upsample_prog, "u_strength", 0.0);
452            gl.draw_arrays(glow::TRIANGLES, 0, 3);
453            draws += 1;
454
455            if fx.auto_shafts && config.light_shafts > 0.0 {
456                self.read_auto_shaft(gl);
457            }
458
459            for level in (0..top).rev() {
460                let (w, h) = self.mip_size[level];
461                gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.up_fbo[level]));
462                gl.viewport(0, 0, w, h);
463                gl.clear(glow::COLOR_BUFFER_BIT);
464                gl.active_texture(glow::TEXTURE0);
465                gl.bind_texture(glow::TEXTURE_2D, Some(self.up_tex[level + 1]));
466                gl.active_texture(glow::TEXTURE1);
467                gl.bind_texture(glow::TEXTURE_2D, Some(self.mip_tex[level]));
468                set_u_f32(gl, self.upsample_prog, "u_radius", radius);
469                let up = (level + 1) as f32 / top.max(1) as f32;
470                set_u_f32(gl, self.upsample_prog, "u_strength", 0.55 + up * 0.30);
471                gl.draw_arrays(glow::TRIANGLES, 0, 3);
472                draws += 1;
473            }
474            gl.active_texture(glow::TEXTURE0);
475        }
476
477        // ── Composite ────────────────────────────────────────────────────────
478        let aa = config.fxaa;
479        if aa {
480            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.ldr_fbo));
481        } else {
482            gl.bind_framebuffer(glow::FRAMEBUFFER, None);
483        }
484        gl.viewport(0, 0, full_w as i32, full_h as i32);
485        gl.clear(glow::COLOR_BUFFER_BIT);
486        gl.use_program(Some(self.composite_prog));
487
488        gl.active_texture(glow::TEXTURE0);
489        gl.bind_texture(glow::TEXTURE_2D, Some(scene_tex));
490        gl.active_texture(glow::TEXTURE2);
491        gl.bind_texture(glow::TEXTURE_2D, Some(self.light_tex));
492        gl.active_texture(glow::TEXTURE3);
493        gl.bind_texture(glow::TEXTURE_2D, Some(self.scene_emission_tex));
494
495        // Unit 1: the bloom result. With bloom off the shader still samples
496        // it for indirect light, shafts and flare, so hand it the emission
497        // buffer rather than the scene: still only the lit things.
498        gl.active_texture(glow::TEXTURE1);
499        if config.bloom_enabled && !self.up_tex.is_empty() {
500            gl.bind_texture(glow::TEXTURE_2D, Some(self.up_tex[0]));
501        } else {
502            gl.bind_texture(glow::TEXTURE_2D, Some(self.scene_emission_tex));
503        }
504
505        let bloom_intensity = if config.bloom_enabled { config.bloom_intensity } else { 0.0 };
506        let p = self.composite_prog;
507        set_u_f32(gl,  p, "u_bloom_intensity",    bloom_intensity);
508        set_u_f32(gl,  p, "u_exposure",           config.exposure);
509        set_u_f32(gl,  p, "u_tonemap",            config.tonemap);
510        set_u_f32(gl,  p, "u_halation",           config.halation);
511        set_u_vec3(gl, p, "u_tint",               config.tint);
512        set_u_vec3(gl, p, "u_lift",               config.lift);
513        set_u_vec3(gl, p, "u_gain",               config.gain);
514        set_u_f32(gl,  p, "u_saturation",         config.saturation);
515        set_u_f32(gl,  p, "u_contrast",           config.contrast);
516        set_u_f32(gl,  p, "u_brightness",         config.brightness);
517        set_u_f32(gl,  p, "u_vignette",           config.vignette);
518        set_u_f32(gl,  p, "u_vignette_softness",  config.vignette_softness);
519        set_u_f32(gl,  p, "u_sharpen",            config.sharpen);
520        set_u_f32(gl,  p, "u_dither",             config.dither);
521        set_u_f32(gl,  p, "u_barrel",             config.barrel);
522        set_u_f32(gl,  p, "u_grain_intensity",    config.film_grain);
523        set_u_f32(gl,  p, "u_grain_seed",         time);
524        set_u_f32(gl,  p, "u_chromatic",          config.chromatic_aberration);
525        set_u_f32(gl,  p, "u_scanline_intensity",
526            if config.scanlines_enabled { config.scanline_intensity } else { 0.0 });
527        set_u_bool(gl, p, "u_scanlines_enabled",  config.scanlines_enabled);
528
529        // Light and moments.
530        let (fw, fh) = (full_w as f32, full_h as f32);
531        set_u_vec2(gl, p, "u_screen",             [fw, fh]);
532        set_u_f32(gl,  p, "u_indirect",           config.indirect_light);
533        set_u_f32(gl,  p, "u_light_shafts",       config.light_shafts);
534        // An explicit source wins; otherwise the brightest thing on screen.
535        let explicit = fx.pack_shaft(fw, fh);
536        if explicit[2] > 0.0 || !fx.auto_shafts || !config.bloom_enabled {
537            set_u_vec3(gl, p, "u_shaft",          explicit);
538            set_u_vec3(gl, p, "u_shaft_tint",     fx.shaft_tint.to_array());
539        } else {
540            let (src, tint) = self.auto_shaft.get();
541            set_u_vec3(gl, p, "u_shaft",          [src[0], src[1], src[2]]);
542            set_u_vec3(gl, p, "u_shaft_tint",     tint);
543        }
544        let (refl, blur) = fx.pack_reflection(fw, fh);
545        if let Some(loc) = gl.get_uniform_location(p, "u_reflection") {
546            gl.uniform_4_f32(Some(&loc), refl[0], refl[1], refl[2], refl[3]);
547        }
548        set_u_f32(gl,  p, "u_reflection_blur",    blur);
549        set_u_f32(gl,  p, "u_haze",               fx.haze.max(0.0));
550        set_u_f32(gl,  p, "u_time",               fx.time);
551        set_u_bool(gl, p, "u_lighting",           lighting);
552        set_u_f32(gl,  p, "u_lens_flare",         config.lens_flare);
553        set_u_vec3(gl, p, "u_flash",              fx.flash.to_array());
554        let (shock, strength, count) = fx.pack_shockwaves(fw, fh);
555        set_u_i32(gl, p, "u_shock_count", count as i32);
556        if count > 0 {
557            let flat: Vec<f32> = shock.iter().flat_map(|v| v.iter().copied()).collect();
558            if let Some(loc) = gl.get_uniform_location(p, "u_shock[0]") {
559                gl.uniform_4_f32_slice(Some(&loc), &flat);
560            }
561            if let Some(loc) = gl.get_uniform_location(p, "u_shock_strength[0]") {
562                gl.uniform_1_f32_slice(Some(&loc), &strength[..MAX_SHOCKWAVES]);
563            }
564        }
565
566        gl.draw_arrays(glow::TRIANGLES, 0, 3);
567        draws += 1;
568
569        // ── Anti-aliasing ────────────────────────────────────────────────────
570        if aa {
571            gl.bind_framebuffer(glow::FRAMEBUFFER, None);
572            gl.viewport(0, 0, full_w as i32, full_h as i32);
573            gl.use_program(Some(self.fxaa_prog));
574            gl.active_texture(glow::TEXTURE0);
575            gl.bind_texture(glow::TEXTURE_2D, Some(self.ldr_tex));
576            gl.draw_arrays(glow::TRIANGLES, 0, 3);
577            draws += 1;
578        }
579
580        gl.active_texture(glow::TEXTURE0);
581        gl.enable(glow::BLEND);
582        gl.bind_vertex_array(None);
583        draws
584    }
585}
586
587// ── FBO helpers ────────────────────────────────────────────────────────────────
588
589/// A colour-renderable texture. `hdr` makes it half-float so it can hold
590/// values past 1.0; otherwise eight bits per channel.
591unsafe fn make_tex(gl: &glow::Context, w: u32, h: u32, hdr: bool) -> glow::Texture {
592    let tex = gl.create_texture().expect("postfx texture");
593    gl.bind_texture(glow::TEXTURE_2D, Some(tex));
594    if hdr {
595        gl.tex_image_2d(
596            glow::TEXTURE_2D, 0, glow::RGBA16F as i32,
597            w as i32, h as i32, 0,
598            glow::RGBA, glow::HALF_FLOAT, glow::PixelUnpackData::Slice(None),
599        );
600    } else {
601        gl.tex_image_2d(
602            glow::TEXTURE_2D, 0, glow::RGBA8 as i32,
603            w as i32, h as i32, 0,
604            glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelUnpackData::Slice(None),
605        );
606    }
607    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32);
608    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32);
609    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S,     glow::CLAMP_TO_EDGE as i32);
610    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T,     glow::CLAMP_TO_EDGE as i32);
611    tex
612}
613
614unsafe fn check_complete(gl: &glow::Context, what: &str) {
615    let status = gl.check_framebuffer_status(glow::FRAMEBUFFER);
616    if status != glow::FRAMEBUFFER_COMPLETE {
617        log::error!("{what} FBO incomplete: 0x{status:X}");
618    }
619}
620
621unsafe fn create_scene_fbo(
622    gl: &glow::Context, w: u32, h: u32,
623) -> (glow::Framebuffer, glow::Texture, glow::Texture, glow::Texture) {
624    let color_tex    = make_tex(gl, w, h, true);
625    let emission_tex = make_tex(gl, w, h, true);
626    let occluder_tex = make_tex(gl, w, h, true);
627
628    let fbo = gl.create_framebuffer().expect("scene fbo");
629    gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
630    gl.framebuffer_texture_2d(
631        glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some(color_tex), 0,
632    );
633    gl.framebuffer_texture_2d(
634        glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT1, glow::TEXTURE_2D, Some(emission_tex), 0,
635    );
636    gl.framebuffer_texture_2d(
637        glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT2, glow::TEXTURE_2D, Some(occluder_tex), 0,
638    );
639    gl.draw_buffers(&[glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2]);
640    check_complete(gl, "scene");
641    gl.bind_framebuffer(glow::FRAMEBUFFER, None);
642    (fbo, color_tex, emission_tex, occluder_tex)
643}
644
645/// One half-float colour target of any size.
646unsafe fn create_hdr_fbo(gl: &glow::Context, w: u32, h: u32, what: &str) -> (glow::Framebuffer, glow::Texture) {
647    let tex = make_tex(gl, w.max(1), h.max(1), true);
648    let fbo = gl.create_framebuffer().expect("hdr fbo");
649    gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
650    gl.framebuffer_texture_2d(
651        glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some(tex), 0,
652    );
653    gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
654    check_complete(gl, what);
655    // Trails and light start from nothing, not from uninitialised memory.
656    gl.clear_color(0.0, 0.0, 0.0, 0.0);
657    gl.clear(glow::COLOR_BUFFER_BIT);
658    gl.bind_framebuffer(glow::FRAMEBUFFER, None);
659    (fbo, tex)
660}
661
662unsafe fn create_ldr_fbo(gl: &glow::Context, w: u32, h: u32) -> (glow::Framebuffer, glow::Texture) {
663    let tex = make_tex(gl, w.max(1), h.max(1), false);
664    let fbo = gl.create_framebuffer().expect("ldr fbo");
665    gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
666    gl.framebuffer_texture_2d(
667        glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some(tex), 0,
668    );
669    gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
670    check_complete(gl, "ldr");
671    gl.bind_framebuffer(glow::FRAMEBUFFER, None);
672    (fbo, tex)
673}
674
675/// Build a chain of halving render targets, largest first.
676///
677/// Stops at eight pixels: below that a level is a colour rather than an image,
678/// and blurring it only spreads the frame's average brightness over the frame.
679unsafe fn create_pyramid(
680    gl: &glow::Context,
681    width: u32,
682    height: u32,
683) -> (Vec<glow::Framebuffer>, Vec<glow::Texture>, Vec<(i32, i32)>) {
684    let mut fbos = Vec::new();
685    let mut texs = Vec::new();
686    let mut sizes = Vec::new();
687    let (mut w, mut h) = ((width / 2).max(1), (height / 2).max(1));
688    for _ in 0..6 {
689        if w < 8 || h < 8 {
690            break;
691        }
692        let tex = make_tex(gl, w, h, true);
693        let fbo = gl.create_framebuffer().expect("bloom mip fbo");
694        gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
695        gl.framebuffer_texture_2d(
696            glow::FRAMEBUFFER,
697            glow::COLOR_ATTACHMENT0,
698            glow::TEXTURE_2D,
699            Some(tex),
700            0,
701        );
702        gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
703        fbos.push(fbo);
704        texs.push(tex);
705        sizes.push((w as i32, h as i32));
706        w = (w / 2).max(1);
707        h = (h / 2).max(1);
708    }
709    gl.bind_framebuffer(glow::FRAMEBUFFER, None);
710    (fbos, texs, sizes)
711}
712
713// ── Shader compilation ─────────────────────────────────────────────────────────
714
715unsafe fn compile_postfx_program(
716    gl: &glow::Context, vert_src: &str, frag_src: &str,
717) -> glow::Program {
718    let vs = gl.create_shader(glow::VERTEX_SHADER).expect("postfx vs");
719    gl.shader_source(vs, vert_src);
720    gl.compile_shader(vs);
721    if !gl.get_shader_compile_status(vs) {
722        panic!("PostFx vertex shader error:\n{}", gl.get_shader_info_log(vs));
723    }
724
725    let fs = gl.create_shader(glow::FRAGMENT_SHADER).expect("postfx fs");
726    gl.shader_source(fs, frag_src);
727    gl.compile_shader(fs);
728    if !gl.get_shader_compile_status(fs) {
729        panic!("PostFx fragment shader error:\n{}", gl.get_shader_info_log(fs));
730    }
731
732    let prog = gl.create_program().expect("postfx program");
733    gl.attach_shader(prog, vs);
734    gl.attach_shader(prog, fs);
735    gl.link_program(prog);
736    if !gl.get_program_link_status(prog) {
737        panic!("PostFx link error:\n{}", gl.get_program_info_log(prog));
738    }
739    gl.detach_shader(prog, vs);
740    gl.detach_shader(prog, fs);
741    gl.delete_shader(vs);
742    gl.delete_shader(fs);
743    prog
744}
745
746// ── Uniform helpers ─────────────────────────────────────────────────────────────
747
748unsafe fn set_u_i32(gl: &glow::Context, prog: glow::Program, name: &str, v: i32) {
749    if let Some(loc) = gl.get_uniform_location(prog, name) {
750        gl.uniform_1_i32(Some(&loc), v);
751    }
752}
753
754unsafe fn set_u_bool(gl: &glow::Context, prog: glow::Program, name: &str, v: bool) {
755    set_u_i32(gl, prog, name, if v { 1 } else { 0 });
756}
757
758unsafe fn set_u_f32(gl: &glow::Context, prog: glow::Program, name: &str, v: f32) {
759    if let Some(loc) = gl.get_uniform_location(prog, name) {
760        gl.uniform_1_f32(Some(&loc), v);
761    }
762}
763
764unsafe fn set_u_vec2(gl: &glow::Context, prog: glow::Program, name: &str, v: [f32; 2]) {
765    if let Some(loc) = gl.get_uniform_location(prog, name) {
766        gl.uniform_2_f32(Some(&loc), v[0], v[1]);
767    }
768}
769
770unsafe fn set_u_vec3(gl: &glow::Context, prog: glow::Program, name: &str, v: [f32; 3]) {
771    if let Some(loc) = gl.get_uniform_location(prog, name) {
772        gl.uniform_3_f32(Some(&loc), v[0], v[1], v[2]);
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::scaled;
779
780    #[test]
781    fn scaled_targets_never_collapse() {
782        assert_eq!(scaled(1280, 800, 1.0), (1280, 800));
783        assert_eq!(scaled(1280, 800, 0.5), (640, 400));
784        assert_eq!(scaled(3, 3, 0.1), (1, 1));
785        assert_eq!(scaled(100, 100, 0.0), (100, 100));
786        assert_eq!(scaled(100, 100, f32::NAN), (100, 100));
787    }
788}