Skip to main content

proof_engine/render/
pipeline.rs

1//! Render pipeline — glutin 0.32 / winit 0.30 window + OpenGL 3.3 Core context,
2//! instanced glyph batch rendering, and the full multi-pass post-processing pipeline
3//! (bloom, chromatic aberration, film grain, vignette, scanlines) wired through
4//! `PostFxPipeline` so that `RenderConfig` actually controls runtime behaviour.
5//!
6//! # Post-processing flow
7//!
8//! ```text
9//! GlyphPass (to scene FBO, dual attachments)
10//!   └─ color    ──┐
11//!   └─ emission ──┤
12//!                 ├─ PostFxPipeline::run(RenderConfig)
13//!                 │   ├─ Bloom H-blur
14//!                 │   ├─ Bloom V-blur   (×2 for softness)
15//!                 │   └─ Composite: scene + bloom + CA + grain + vignette → screen
16//!                 └─► Default framebuffer
17//! ```
18
19use std::num::NonZeroU32;
20use std::ffi::CString;
21use std::time::{Duration, Instant};
22
23use glutin::config::ConfigTemplateBuilder;
24use glutin::context::{ContextApi, ContextAttributesBuilder, NotCurrentGlContext,
25                      PossiblyCurrentContext, Version};
26use glutin::display::{GetGlDisplay, GlDisplay};
27use glutin::surface::{GlSurface, Surface, WindowSurface};
28use glutin_winit::{DisplayBuilder, GlWindow};
29use glow::HasContext;
30use raw_window_handle::HasWindowHandle;
31use winit::dpi::LogicalSize;
32use winit::event::{ElementState, Event, MouseButton, MouseScrollDelta, WindowEvent};
33use winit::event_loop::EventLoop;
34use winit::keyboard::{KeyCode, PhysicalKey};
35use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
36use winit::window::Window;
37use glam::{Mat4, Vec2, Vec3};
38use bytemuck::cast_slice;
39
40use crate::config::{EngineConfig, RenderConfig};
41use crate::scene::Scene;
42use crate::render::camera::ProofCamera;
43use crate::render::postfx::PostFxPipeline;
44use crate::input::{InputState, Key};
45use crate::glyph::atlas::FontAtlas;
46use crate::glyph::batch::GlyphInstance;
47
48// ── Glyph vertex shader ────────────────────────────────────────────────────────
49
50const VERT_SRC: &str = r#"
51#version 330 core
52
53layout(location = 0) in vec2  v_pos;
54layout(location = 1) in vec2  v_uv;
55
56layout(location = 2)  in vec3  i_position;
57layout(location = 3)  in vec2  i_scale;
58layout(location = 4)  in float i_rotation;
59layout(location = 5)  in vec4  i_color;
60layout(location = 6)  in float i_emission;
61layout(location = 7)  in vec3  i_glow_color;
62layout(location = 8)  in float i_glow_radius;
63layout(location = 9)  in vec2  i_uv_offset;
64layout(location = 10) in vec2  i_uv_size;
65
66uniform mat4 u_view_proj;
67
68out vec2  f_uv;
69out vec4  f_color;
70out float f_emission;
71out vec3  f_glow_color;
72out float f_glow_radius;
73
74void main() {
75    float c = cos(i_rotation);
76    float s = sin(i_rotation);
77    vec2 rotated = vec2(
78        v_pos.x * c - v_pos.y * s,
79        v_pos.x * s + v_pos.y * c
80    ) * i_scale;
81
82    gl_Position = u_view_proj * vec4(i_position + vec3(rotated, 0.0), 1.0);
83    gl_Position.y = -gl_Position.y;  // FBO renders upside-down relative to screen
84
85    f_uv         = i_uv_offset + v_uv * i_uv_size;
86    f_color      = i_color;
87    f_emission   = i_emission;
88    f_glow_color = i_glow_color;
89    f_glow_radius = i_glow_radius;
90}
91"#;
92
93/// Glyph fragment shader with dual output: color + emission.
94///
95/// `o_color`    → COLOR_ATTACHMENT0 — blended scene color
96/// `o_emission` → COLOR_ATTACHMENT1 — bloom input (high-intensity glowing pixels)
97const FRAG_SRC: &str = r#"
98#version 330 core
99
100in vec2  f_uv;
101in vec4  f_color;
102in float f_emission;
103in vec3  f_glow_color;
104in float f_glow_radius;
105
106uniform sampler2D u_atlas;
107
108layout(location = 0) out vec4 o_color;
109layout(location = 1) out vec4 o_emission;
110
111void main() {
112    float alpha = texture(u_atlas, f_uv).r;
113    if (alpha < 0.05) discard;
114
115    // Base color with emission tint
116    float em  = clamp(f_emission * 0.5, 0.0, 1.0);
117    vec3  col = mix(f_color.rgb, f_glow_color, em);
118    o_color   = vec4(col, alpha * f_color.a);
119
120    // Emission output: only bright, glowing pixels go to the bloom input.
121    // The bloom amount is proportional to (emission - 0.3), clamped.
122    float bloom_strength = clamp(f_emission - 0.3, 0.0, 1.0);
123    // Add glow radius influence — higher glow_radius means more bloom spread
124    float glow_boost     = clamp(f_glow_radius * 0.15, 0.0, 0.8);
125    o_emission = vec4(f_glow_color * (bloom_strength + glow_boost), alpha * f_color.a);
126}
127"#;
128
129// ── Unit quad geometry ─────────────────────────────────────────────────────────
130
131/// Unit quad: 6 vertices (2 CCW triangles), each: [pos_x, pos_y, uv_x, uv_y]
132#[rustfmt::skip]
133const QUAD_VERTS: [f32; 24] = [
134    -0.5,  0.5,  0.0, 1.0,
135    -0.5, -0.5,  0.0, 0.0,
136     0.5,  0.5,  1.0, 1.0,
137    -0.5, -0.5,  0.0, 0.0,
138     0.5, -0.5,  1.0, 0.0,
139     0.5,  0.5,  1.0, 1.0,
140];
141
142// ── FrameStats ─────────────────────────────────────────────────────────────────
143
144/// Per-frame rendering statistics.
145#[derive(Clone, Debug, Default)]
146pub struct FrameStats {
147    /// Frames per second (rolling average over 60 frames).
148    pub fps:              f32,
149    /// Time of last frame in seconds.
150    pub dt:               f32,
151    /// Number of glyphs drawn this frame.
152    pub glyph_count:      usize,
153    /// Number of particles drawn this frame.
154    pub particle_count:   usize,
155    /// Number of draw calls this frame.
156    pub draw_calls:       u32,
157    /// Total frame number since engine start.
158    pub frame_number:     u64,
159}
160
161/// Rolling FPS calculator over N frames.
162struct FpsCounter {
163    samples:   [f32; 60],
164    head:      usize,
165    filled:    bool,
166}
167
168impl FpsCounter {
169    fn new() -> Self { Self { samples: [0.016; 60], head: 0, filled: false } }
170
171    fn push(&mut self, dt: f32) {
172        self.samples[self.head] = dt.max(f32::EPSILON);
173        self.head = (self.head + 1) % 60;
174        if self.head == 0 { self.filled = true; }
175    }
176
177    fn fps(&self) -> f32 {
178        let count = if self.filled { 60 } else { self.head.max(1) };
179        let avg_dt: f32 = self.samples[..count].iter().sum::<f32>() / count as f32;
180        1.0 / avg_dt
181    }
182}
183
184// ── Pipeline ───────────────────────────────────────────────────────────────────
185
186/// The main render pipeline.
187///
188/// Created once by `ProofEngine::new()` and kept alive for the duration of the game.
189/// Owns the window, OpenGL context, shader programs, font atlas, glyph VAO, and
190/// the post-processing pipeline.
191#[allow(dead_code)]
192pub struct Pipeline {
193    // ── Runtime info ──────────────────────────────────────────────────────────
194    pub width:   u32,
195    pub height:  u32,
196    pub stats:   FrameStats,
197    running:     bool,
198
199    // ── Config snapshot (not a reference — the engine owns EngineConfig) ──────
200    render_config: RenderConfig,
201
202    // ── Windowing ────────────────────────────────────────────────────────────
203    event_loop: EventLoop<()>,
204    window:     Window,
205    surface:    Surface<WindowSurface>,
206    context:    PossiblyCurrentContext,
207
208    // ── OpenGL glyph pass ─────────────────────────────────────────────────────
209    gl:            glow::Context,
210    program:       glow::Program,
211    vao:           glow::VertexArray,
212    quad_vbo:      glow::Buffer,
213    instance_vbo:  glow::Buffer,
214    atlas_tex:     glow::Texture,
215    loc_view_proj: glow::UniformLocation,
216
217    // ── Post-processing pipeline (the real deal — reads RenderConfig) ─────────
218    postfx: PostFxPipeline,
219
220    // ── Font atlas ────────────────────────────────────────────────────────────
221    atlas: FontAtlas,
222
223    // ── CPU-side glyph batch ──────────────────────────────────────────────────
224    instances: Vec<GlyphInstance>,
225
226    // ── Timing ────────────────────────────────────────────────────────────────
227    fps_counter:  FpsCounter,
228    frame_start:  Instant,
229    scene_time:   f32,
230
231    // ── Mouse state ───────────────────────────────────────────────────────────
232    mouse_pos:      Vec2,
233    mouse_pos_prev: Vec2,
234    /// Normalized device coordinates (NDC) of the mouse cursor.
235    mouse_ndc:      Vec2,
236
237    // ── Raw events for external consumers (egui) ────────────────────────────
238    /// Raw winit WindowEvents collected during poll_events, drained by consumers.
239    pub raw_window_events: Vec<winit::event::WindowEvent>,
240}
241
242impl Pipeline {
243    /// Initialize window, OpenGL 3.3 Core context, shader programs, font atlas, and PostFxPipeline.
244    pub fn init(config: &EngineConfig) -> Self {
245        // ── 1. winit EventLoop ────────────────────────────────────────────────
246        let event_loop = EventLoop::new().expect("EventLoop::new");
247
248        // ── 2. Window attributes (winit 0.30 API) ─────────────────────────────
249        let window_attrs = Window::default_attributes()
250            .with_title(&config.window_title)
251            .with_inner_size(LogicalSize::new(config.window_width, config.window_height))
252            .with_resizable(true);
253
254        // ── 3. GL config via DisplayBuilder (glutin-winit 0.5) ────────────────
255        let template = ConfigTemplateBuilder::new()
256            .with_alpha_size(8)
257            .with_depth_size(0);
258
259        let display_builder = DisplayBuilder::new()
260            .with_window_attributes(Some(window_attrs));
261
262        let (window, gl_config) = display_builder
263            .build(&event_loop, template, |mut configs| {
264                configs.next().expect("no suitable GL config found")
265            })
266            .expect("DisplayBuilder::build failed");
267
268        let window = window.expect("window was not created");
269        let display = gl_config.display();
270
271        // ── 4. OpenGL 3.3 Core context ────────────────────────────────────────
272        let raw_handle = window.window_handle().unwrap().as_raw();
273        let ctx_attrs = ContextAttributesBuilder::new()
274            .with_context_api(ContextApi::OpenGl(Some(Version::new(3, 3))))
275            .build(Some(raw_handle));
276
277        let not_current = unsafe {
278            display.create_context(&gl_config, &ctx_attrs)
279                   .expect("create_context failed")
280        };
281
282        // ── 5. Window surface ─────────────────────────────────────────────────
283        let size = window.inner_size();
284        let w = size.width.max(1);
285        let h = size.height.max(1);
286
287        let surface_attrs = window
288            .build_surface_attributes(Default::default())
289            .expect("build_surface_attributes failed");
290
291        let surface = unsafe {
292            display.create_window_surface(&gl_config, &surface_attrs)
293                   .expect("create_window_surface failed")
294        };
295
296        // ── 6. Make current ───────────────────────────────────────────────────
297        let context = not_current.make_current(&surface)
298                                 .expect("make_current failed");
299
300        // ── 7. glow context from proc address ─────────────────────────────────
301        let gl = unsafe {
302            glow::Context::from_loader_function(|sym| {
303                let sym_c = CString::new(sym).unwrap();
304                display.get_proc_address(sym_c.as_c_str()) as *const _
305            })
306        };
307
308        // ── 8. Compile glyph program ──────────────────────────────────────────
309        let program = unsafe { compile_program(&gl, VERT_SRC, FRAG_SRC) };
310        let loc_view_proj = unsafe {
311            gl.get_uniform_location(program, "u_view_proj")
312              .expect("uniform u_view_proj not found")
313        };
314        unsafe {
315            gl.use_program(Some(program));
316            if let Some(loc) = gl.get_uniform_location(program, "u_atlas") {
317                gl.uniform_1_i32(Some(&loc), 0);
318            }
319        }
320
321        // ── 9. Geometry: VAO + VBOs ───────────────────────────────────────────
322        let (vao, quad_vbo, instance_vbo) = unsafe { setup_vao(&gl) };
323
324        // ── 10. Font atlas ────────────────────────────────────────────────────
325        let atlas     = FontAtlas::build(config.render.font_size as f32);
326        let atlas_tex = unsafe { upload_atlas(&gl, &atlas) };
327
328        // ── 11. PostFxPipeline — dual-attachment FBOs + bloom shaders ────────
329        let postfx = unsafe { PostFxPipeline::new(&gl, w, h) };
330
331        // ── 12. Global GL state ───────────────────────────────────────────────
332        unsafe {
333            gl.enable(glow::BLEND);
334            gl.blend_func(glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA);
335            gl.clear_color(0.02, 0.02, 0.05, 1.0);
336            gl.viewport(0, 0, w as i32, h as i32);
337        }
338
339        log::info!(
340            "Pipeline ready — {}×{} — font atlas {}×{} ({} chars) — PostFxPipeline wired",
341            w, h, atlas.width, atlas.height, atlas.uvs.len()
342        );
343
344        Self {
345            width: w, height: h,
346            stats: FrameStats::default(),
347            running: true,
348            render_config: config.render.clone(),
349            event_loop, window, surface, context,
350            gl, program, vao, quad_vbo, instance_vbo, atlas_tex, loc_view_proj,
351            postfx,
352            atlas,
353            instances: Vec::with_capacity(8192),
354            fps_counter: FpsCounter::new(),
355            frame_start: Instant::now(),
356            scene_time: 0.0,
357            mouse_pos: Vec2::ZERO,
358            mouse_pos_prev: Vec2::ZERO,
359            mouse_ndc: Vec2::ZERO,
360            raw_window_events: Vec::new(),
361        }
362    }
363
364    /// Update the render config used by the PostFx pipeline this frame.
365    /// Call from `ProofEngine::run()` whenever the config changes.
366    pub fn update_render_config(&mut self, config: &RenderConfig) {
367        self.render_config = config.clone();
368    }
369
370    /// Poll window events and update `InputState`. Returns false on quit.
371    pub fn poll_events(&mut self, input: &mut InputState) -> bool {
372        input.clear_frame();
373        self.mouse_pos_prev = self.mouse_pos;
374
375        let mut should_exit = false;
376        let mut resize:     Option<(u32, u32)>  = None;
377        let mut key_events: Vec<(KeyCode, bool)> = Vec::new();
378        let mut mouse_moved:     Option<(f64, f64)> = None;
379        let mut mouse_buttons:   Vec<(MouseButton, bool)> = Vec::new();
380        let mut scroll_delta:    f32 = 0.0;
381
382        self.raw_window_events.clear();
383
384        #[allow(deprecated)]
385        let status = self.event_loop.pump_events(Some(Duration::ZERO), |event, elwt| {
386            match event {
387                Event::WindowEvent { event: we, .. } => match we {
388                    WindowEvent::CloseRequested => {
389                        should_exit = true;
390                        elwt.exit();
391                    }
392                    WindowEvent::Resized(s) => {
393                        resize = Some((s.width, s.height));
394                    }
395                    WindowEvent::KeyboardInput { event: key_ev, .. } => {
396                        if let PhysicalKey::Code(kc) = key_ev.physical_key {
397                            let pressed = key_ev.state == ElementState::Pressed;
398                            key_events.push((kc, pressed));
399                        }
400                    }
401                    WindowEvent::CursorMoved { position, .. } => {
402                        mouse_moved = Some((position.x, position.y));
403                    }
404                    WindowEvent::MouseInput { button, state, .. } => {
405                        let pressed = state == ElementState::Pressed;
406                        mouse_buttons.push((button, pressed));
407                    }
408                    WindowEvent::MouseWheel { delta, .. } => {
409                        scroll_delta += match delta {
410                            MouseScrollDelta::LineDelta(_, y) => y,
411                            MouseScrollDelta::PixelDelta(d)   => d.y as f32 / 40.0,
412                        };
413                    }
414                    _ => {}
415                }
416                _ => {}
417            }
418        });
419
420        // ── Apply resize ───────────────────────────────────────────────────────
421        if let Some((w, h)) = resize {
422            if w > 0 && h > 0 {
423                self.surface.resize(
424                    &self.context,
425                    NonZeroU32::new(w).unwrap(),
426                    NonZeroU32::new(h).unwrap(),
427                );
428                unsafe { self.gl.viewport(0, 0, w as i32, h as i32); }
429                self.width  = w;
430                self.height = h;
431                input.window_resized = Some((w, h));
432                unsafe { self.postfx.resize(&self.gl, w, h); }
433            }
434        }
435
436        // ── Apply key events ───────────────────────────────────────────────────
437        for (kc, pressed) in key_events {
438            if let Some(key) = keycode_to_engine(kc) {
439                if pressed {
440                    input.keys_pressed.insert(key);
441                    input.keys_just_pressed.insert(key);
442                } else {
443                    input.keys_pressed.remove(&key);
444                    input.keys_just_released.insert(key);
445                }
446            }
447        }
448
449        // ── Apply mouse events ─────────────────────────────────────────────────
450        if let Some((x, y)) = mouse_moved {
451            self.mouse_pos = Vec2::new(x as f32, y as f32);
452            input.mouse_x = x as f32;
453            input.mouse_y = y as f32;
454            // Compute NDC: x/y ∈ [0, width/height] → [-1, 1]
455            let w = self.width.max(1) as f32;
456            let h = self.height.max(1) as f32;
457            self.mouse_ndc = Vec2::new(
458                (x as f32 / w) * 2.0 - 1.0,
459                1.0 - (y as f32 / h) * 2.0,
460            );
461            input.mouse_ndc = self.mouse_ndc;
462            input.mouse_delta = self.mouse_pos - self.mouse_pos_prev;
463        }
464
465        for (button, pressed) in mouse_buttons {
466            match button {
467                MouseButton::Left   => {
468                    if pressed { input.mouse_left_just_pressed  = true; }
469                    else       { input.mouse_left_just_released = true; }
470                    input.mouse_left = pressed;
471                }
472                MouseButton::Right  => {
473                    if pressed { input.mouse_right_just_pressed  = true; }
474                    else       { input.mouse_right_just_released = true; }
475                    input.mouse_right = pressed;
476                }
477                MouseButton::Middle => {
478                    if pressed { input.mouse_middle_just_pressed = true; }
479                    input.mouse_middle = pressed;
480                }
481                _ => {}
482            }
483        }
484
485        input.scroll_delta = scroll_delta;
486
487        // ── Exit check ─────────────────────────────────────────────────────────
488        if should_exit || matches!(status, PumpStatus::Exit(_)) {
489            self.running = false;
490        }
491        self.running
492    }
493
494    /// Collect all visible glyphs + particles from the scene, upload to the GPU,
495    /// and execute the full multi-pass rendering pipeline.
496    pub fn render(&mut self, scene: &Scene, camera: &ProofCamera) {
497        // ── Frame timing ───────────────────────────────────────────────────────
498        let now = Instant::now();
499        let dt  = now.duration_since(self.frame_start).as_secs_f32().min(0.1);
500        self.frame_start = now;
501        self.scene_time  = scene.time;
502
503        self.fps_counter.push(dt);
504        self.stats.fps          = self.fps_counter.fps();
505        self.stats.dt           = dt;
506        self.stats.frame_number += 1;
507
508        // ── Build camera matrices ──────────────────────────────────────────────
509        let pos    = camera.position.position();
510        let tgt    = camera.target.position();
511        let fov    = camera.fov.position;
512        let aspect = if self.height > 0 { self.width as f32 / self.height as f32 } else { 1.0 };
513        let view      = Mat4::look_at_rh(pos, tgt, Vec3::Y);
514        let proj      = Mat4::perspective_rh_gl(fov.to_radians(), aspect, camera.near, camera.far);
515        let view_proj = proj * view;
516
517        // ── Build glyph batch ──────────────────────────────────────────────────
518        self.instances.clear();
519        let mut glyph_count    = 0;
520        let mut particle_count = 0;
521
522        // Glyphs sorted by render layer (entity < particle < UI)
523        for (_, glyph) in scene.glyphs.iter() {
524            if !glyph.visible { continue; }
525            let life_scale = if let Some(ref f) = glyph.life_function {
526                f.evaluate(scene.time, 0.0)
527            } else {
528                1.0
529            };
530            let uv = self.atlas.uv_for(glyph.character);
531            self.instances.push(GlyphInstance {
532                position:    glyph.position.to_array(),
533                scale:       [glyph.scale.x * life_scale, glyph.scale.y * life_scale],
534                rotation:    glyph.rotation,
535                color:       glyph.color.to_array(),
536                emission:    glyph.emission,
537                glow_color:  glyph.glow_color.to_array(),
538                glow_radius: glyph.glow_radius,
539                uv_offset:   uv.offset(),
540                uv_size:     uv.size(),
541                _pad:        [0.0; 2],
542            });
543            glyph_count += 1;
544        }
545
546        for particle in scene.particles.iter() {
547            let g = &particle.glyph;
548            if !g.visible { continue; }
549            let uv = self.atlas.uv_for(g.character);
550            self.instances.push(GlyphInstance {
551                position:    g.position.to_array(),
552                scale:       [g.scale.x, g.scale.y],
553                rotation:    g.rotation,
554                color:       g.color.to_array(),
555                emission:    g.emission,
556                glow_color:  g.glow_color.to_array(),
557                glow_radius: g.glow_radius,
558                uv_offset:   uv.offset(),
559                uv_size:     uv.size(),
560                _pad:        [0.0; 2],
561            });
562            particle_count += 1;
563        }
564
565        self.stats.glyph_count    = glyph_count;
566        self.stats.particle_count = particle_count;
567        self.stats.draw_calls     = 0;
568
569        // ── Execute render passes ──────────────────────────────────────────────
570        unsafe { self.execute_render_passes(view_proj); }
571    }
572
573    /// Swap back buffer to screen. Returns false on window close.
574    pub fn swap(&mut self) -> bool {
575        if let Err(e) = self.surface.swap_buffers(&self.context) {
576            log::error!("swap_buffers failed: {e}");
577            self.running = false;
578        }
579        self.running
580    }
581
582    // ── Public accessors for editor/egui integration ──────────────────────────
583
584    /// Get a reference to the raw glow OpenGL context.
585    /// Used by egui-glow to render UI on top of the scene.
586    pub fn gl(&self) -> &glow::Context {
587        &self.gl
588    }
589
590    /// Get the window reference (for egui-winit event processing).
591    pub fn window(&self) -> &Window {
592        &self.window
593    }
594
595    /// Get the current window size.
596    pub fn window_size(&self) -> (u32, u32) {
597        let size = self.window.inner_size();
598        (size.width, size.height)
599    }
600
601    // ── Private render pass execution ─────────────────────────────────────────
602
603    unsafe fn execute_render_passes(&mut self, view_proj: Mat4) {
604        let gl = &self.gl;
605
606        // ── Pass 1: Render glyphs into PostFxPipeline's dual-attachment scene FBO ──
607        //
608        // Attachment 0 → scene_color_tex  (regular glyph colors)
609        // Attachment 1 → scene_emission_tex (bloom-input: high-emission pixels only)
610        gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.postfx.scene_fbo));
611        gl.viewport(0, 0, self.width as i32, self.height as i32);
612        gl.clear(glow::COLOR_BUFFER_BIT);
613
614        if !self.instances.is_empty() {
615            // Upload instance data
616            gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.instance_vbo));
617            gl.buffer_data_u8_slice(
618                glow::ARRAY_BUFFER,
619                cast_slice(self.instances.as_slice()),
620                glow::DYNAMIC_DRAW,
621            );
622
623            // Draw all glyphs in one instanced call
624            gl.use_program(Some(self.program));
625            gl.uniform_matrix_4_f32_slice(
626                Some(&self.loc_view_proj),
627                false,
628                &view_proj.to_cols_array(),
629            );
630            gl.active_texture(glow::TEXTURE0);
631            gl.bind_texture(glow::TEXTURE_2D, Some(self.atlas_tex));
632            gl.bind_vertex_array(Some(self.vao));
633            gl.draw_arrays_instanced(glow::TRIANGLES, 0, 6, self.instances.len() as i32);
634            self.stats.draw_calls += 1;
635        }
636
637        // ── Passes 2-5: PostFxPipeline handles bloom + compositing ────────────
638        //
639        // PostFxPipeline reads render_config.bloom_enabled, bloom_intensity,
640        // chromatic_aberration, film_grain, scanlines_enabled, etc.
641        self.postfx.run(gl, &self.render_config, self.width, self.height, self.scene_time);
642        self.stats.draw_calls += 4; // bloom H, bloom V, bloom H2, bloom V2, composite
643    }
644}
645
646// ── GL helper functions ────────────────────────────────────────────────────────
647
648/// Compile a vertex + fragment shader pair into a linked GL program.
649unsafe fn compile_program(gl: &glow::Context, vert_src: &str, frag_src: &str) -> glow::Program {
650    let vs = gl.create_shader(glow::VERTEX_SHADER).expect("create vertex shader");
651    gl.shader_source(vs, vert_src);
652    gl.compile_shader(vs);
653    if !gl.get_shader_compile_status(vs) {
654        let log = gl.get_shader_info_log(vs);
655        panic!("Vertex shader compile error:\n{log}");
656    }
657
658    let fs = gl.create_shader(glow::FRAGMENT_SHADER).expect("create fragment shader");
659    gl.shader_source(fs, frag_src);
660    gl.compile_shader(fs);
661    if !gl.get_shader_compile_status(fs) {
662        let log = gl.get_shader_info_log(fs);
663        panic!("Fragment shader compile error:\n{log}");
664    }
665
666    let prog = gl.create_program().expect("create shader program");
667    gl.attach_shader(prog, vs);
668    gl.attach_shader(prog, fs);
669    gl.link_program(prog);
670    if !gl.get_program_link_status(prog) {
671        let log = gl.get_program_info_log(prog);
672        panic!("Shader link error:\n{log}");
673    }
674
675    gl.detach_shader(prog, vs);
676    gl.detach_shader(prog, fs);
677    gl.delete_shader(vs);
678    gl.delete_shader(fs);
679    prog
680}
681
682/// Create VAO with per-vertex quad data (locations 0–1) and per-instance data (locations 2–10).
683unsafe fn setup_vao(gl: &glow::Context) -> (glow::VertexArray, glow::Buffer, glow::Buffer) {
684    let vao = gl.create_vertex_array().expect("create vao");
685    gl.bind_vertex_array(Some(vao));
686
687    // ── Quad geometry VBO ─────────────────────────────────────────────────────
688    let quad_vbo = gl.create_buffer().expect("create quad_vbo");
689    gl.bind_buffer(glow::ARRAY_BUFFER, Some(quad_vbo));
690    gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, cast_slice(&QUAD_VERTS), glow::STATIC_DRAW);
691    // location 0: vec2 v_pos  (offset 0, stride 16)
692    gl.vertex_attrib_pointer_f32(0, 2, glow::FLOAT, false, 16, 0);
693    gl.enable_vertex_attrib_array(0);
694    // location 1: vec2 v_uv   (offset 8, stride 16)
695    gl.vertex_attrib_pointer_f32(1, 2, glow::FLOAT, false, 16, 8);
696    gl.enable_vertex_attrib_array(1);
697
698    // ── Instance VBO (per-glyph data) ─────────────────────────────────────────
699    let instance_vbo = gl.create_buffer().expect("create instance_vbo");
700    gl.bind_buffer(glow::ARRAY_BUFFER, Some(instance_vbo));
701
702    let stride = std::mem::size_of::<GlyphInstance>() as i32;
703
704    // Macro: set up an instanced float attribute.
705    macro_rules! inst_attr {
706        ($loc:expr, $count:expr, $off:expr) => {{
707            gl.vertex_attrib_pointer_f32($loc, $count, glow::FLOAT, false, stride, $off);
708            gl.enable_vertex_attrib_array($loc);
709            gl.vertex_attrib_divisor($loc, 1); // advance once per instance
710        }};
711    }
712
713    inst_attr!(2,  3,  0);  // i_position   vec3   @ byte 0
714    inst_attr!(3,  2, 12);  // i_scale      vec2   @ byte 12
715    inst_attr!(4,  1, 20);  // i_rotation   float  @ byte 20
716    inst_attr!(5,  4, 24);  // i_color      vec4   @ byte 24
717    inst_attr!(6,  1, 40);  // i_emission   float  @ byte 40
718    inst_attr!(7,  3, 44);  // i_glow_color vec3   @ byte 44
719    inst_attr!(8,  1, 56);  // i_glow_radius float @ byte 56
720    inst_attr!(9,  2, 60);  // i_uv_offset  vec2   @ byte 60
721    inst_attr!(10, 2, 68);  // i_uv_size    vec2   @ byte 68
722    // bytes 76-83: _pad (2× f32, needed to keep GlyphInstance 84-byte aligned)
723
724    (vao, quad_vbo, instance_vbo)
725}
726
727/// Upload a FontAtlas as an R8 GL texture and return the handle.
728unsafe fn upload_atlas(gl: &glow::Context, atlas: &FontAtlas) -> glow::Texture {
729    let tex = gl.create_texture().expect("create atlas texture");
730    gl.bind_texture(glow::TEXTURE_2D, Some(tex));
731    gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
732    gl.tex_image_2d(
733        glow::TEXTURE_2D, 0, glow::R8 as i32,
734        atlas.width as i32, atlas.height as i32,
735        0, glow::RED, glow::UNSIGNED_BYTE,
736        glow::PixelUnpackData::Slice(Some(&atlas.pixels)),
737    );
738    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32);
739    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32);
740    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32);
741    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32);
742    tex
743}
744
745// ── KeyCode → engine Key mapping ──────────────────────────────────────────────
746
747/// Map a winit `KeyCode` to the engine's `Key` enum. Returns `None` for unknown keys.
748fn keycode_to_engine(kc: KeyCode) -> Option<Key> {
749    Some(match kc {
750        KeyCode::KeyA => Key::A, KeyCode::KeyB => Key::B, KeyCode::KeyC => Key::C,
751        KeyCode::KeyD => Key::D, KeyCode::KeyE => Key::E, KeyCode::KeyF => Key::F,
752        KeyCode::KeyG => Key::G, KeyCode::KeyH => Key::H, KeyCode::KeyI => Key::I,
753        KeyCode::KeyJ => Key::J, KeyCode::KeyK => Key::K, KeyCode::KeyL => Key::L,
754        KeyCode::KeyM => Key::M, KeyCode::KeyN => Key::N, KeyCode::KeyO => Key::O,
755        KeyCode::KeyP => Key::P, KeyCode::KeyQ => Key::Q, KeyCode::KeyR => Key::R,
756        KeyCode::KeyS => Key::S, KeyCode::KeyT => Key::T, KeyCode::KeyU => Key::U,
757        KeyCode::KeyV => Key::V, KeyCode::KeyW => Key::W, KeyCode::KeyX => Key::X,
758        KeyCode::KeyY => Key::Y, KeyCode::KeyZ => Key::Z,
759        KeyCode::Digit1 => Key::Num1, KeyCode::Digit2 => Key::Num2,
760        KeyCode::Digit3 => Key::Num3, KeyCode::Digit4 => Key::Num4,
761        KeyCode::Digit5 => Key::Num5, KeyCode::Digit6 => Key::Num6,
762        KeyCode::Digit7 => Key::Num7, KeyCode::Digit8 => Key::Num8,
763        KeyCode::Digit9 => Key::Num9, KeyCode::Digit0 => Key::Num0,
764        KeyCode::ArrowUp    => Key::Up,    KeyCode::ArrowDown  => Key::Down,
765        KeyCode::ArrowLeft  => Key::Left,  KeyCode::ArrowRight => Key::Right,
766        KeyCode::Enter | KeyCode::NumpadEnter => Key::Enter,
767        KeyCode::Escape     => Key::Escape,
768        KeyCode::Space      => Key::Space,
769        KeyCode::Backspace  => Key::Backspace,
770        KeyCode::Tab        => Key::Tab,
771        KeyCode::ShiftLeft   => Key::LShift,  KeyCode::ShiftRight   => Key::RShift,
772        KeyCode::ControlLeft => Key::LCtrl,   KeyCode::ControlRight => Key::RCtrl,
773        KeyCode::AltLeft     => Key::LAlt,    KeyCode::AltRight     => Key::RAlt,
774        KeyCode::F1  => Key::F1,  KeyCode::F2  => Key::F2,  KeyCode::F3  => Key::F3,
775        KeyCode::F4  => Key::F4,  KeyCode::F5  => Key::F5,  KeyCode::F6  => Key::F6,
776        KeyCode::F7  => Key::F7,  KeyCode::F8  => Key::F8,  KeyCode::F9  => Key::F9,
777        KeyCode::F10 => Key::F10, KeyCode::F11 => Key::F11, KeyCode::F12 => Key::F12,
778        KeyCode::Slash        => Key::Slash,
779        KeyCode::Backslash    => Key::Backslash,
780        KeyCode::Period       => Key::Period,
781        KeyCode::Comma        => Key::Comma,
782        KeyCode::Semicolon    => Key::Semicolon,
783        KeyCode::Quote        => Key::Quote,
784        KeyCode::BracketLeft  => Key::LBracket,
785        KeyCode::BracketRight => Key::RBracket,
786        KeyCode::Minus        => Key::Minus,
787        KeyCode::Equal        => Key::Equals,
788        KeyCode::Backquote    => Key::Backtick,
789        KeyCode::PageUp       => Key::PageUp,
790        KeyCode::PageDown     => Key::PageDown,
791        KeyCode::Home         => Key::Home,
792        KeyCode::End          => Key::End,
793        KeyCode::Insert       => Key::Insert,
794        KeyCode::Delete       => Key::Delete,
795        _ => return None,
796    })
797}