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;
47use crate::render::ui_layer::UiLayer;
48use crate::render::screen_fx::ScreenFx;
49use crate::particle::gpu_density::{GpuDensityEntityData, GpuDensityRenderer};
50
51// ── Glyph vertex shader ────────────────────────────────────────────────────────
52
53const VERT_SRC: &str = r#"
54#version 330 core
55
56layout(location = 0) in vec2  v_pos;
57layout(location = 1) in vec2  v_uv;
58
59layout(location = 2)  in vec3  i_position;
60layout(location = 3)  in vec2  i_scale;
61layout(location = 4)  in float i_rotation;
62layout(location = 5)  in vec4  i_color;
63layout(location = 6)  in float i_emission;
64layout(location = 7)  in vec3  i_glow_color;
65layout(location = 8)  in float i_glow_radius;
66layout(location = 9)  in vec2  i_uv_offset;
67layout(location = 10) in vec2  i_uv_size;
68// x: 1.0 for a fill (ground, panel) that must not cast shadow.
69layout(location = 11) in vec2  i_flags;
70
71uniform mat4 u_view_proj;
72// Oversampling: each base instance is rendered u_n_copies times.
73// vertex_attrib_divisor is set to u_n_copies so all copies share the same
74// instance attributes. gl_InstanceID / u_n_copies selects the base particle;
75// gl_InstanceID % u_n_copies selects the copy and drives the jitter hash.
76uniform uint u_n_copies;
77
78out vec2  f_uv;
79out vec4  f_color;
80out float f_emission;
81out vec3  f_glow_color;
82out float f_glow_radius;
83out float f_fill;
84
85// Hash matching the CPU hf(seed, v) function — same constants, same bit ops.
86float hf(uint seed, uint v) {
87    uint n = seed * 374761393u + v * 668265263u;
88    n ^= (n >> 13u);
89    n *= 0x5851F42Du;
90    n ^= (n >> 16u);
91    return float(n & 0x00FFFFFFu) / float(0x01000000u);
92}
93
94void main() {
95    float c = cos(i_rotation);
96    float s = sin(i_rotation);
97    vec2 rotated = vec2(
98        v_pos.x * c - v_pos.y * s,
99        v_pos.x * s + v_pos.y * c
100    ) * i_scale;
101
102    // Per-copy position jitter — only active when oversampling.
103    // Seeds mirror the CPU oversampling: seed = real_id*17 + copy_id, slots 30/31.
104    vec3 jitter = vec3(0.0);
105    if (u_n_copies > 1u) {
106        uint real_id = uint(gl_InstanceID) / u_n_copies;
107        uint copy_id = uint(gl_InstanceID) % u_n_copies;
108        uint jseed   = real_id * 17u + copy_id;
109        jitter = vec3(
110            (hf(jseed, 30u) - 0.5) * i_scale.x * 0.70,
111            (hf(jseed, 31u) - 0.5) * i_scale.y * 0.70,
112            0.0
113        );
114    }
115
116    gl_Position = u_view_proj * vec4(i_position + jitter + vec3(rotated, 0.0), 1.0);
117    gl_Position.y = -gl_Position.y;  // FBO renders upside-down relative to screen
118
119    f_uv         = i_uv_offset + v_uv * i_uv_size;
120    // Divide alpha and emission by n_copies so n_copies additive contributions
121    // sum to the same luminance as a single unscaled instance.
122    float inv_n  = 1.0 / float(u_n_copies);
123    f_color      = vec4(i_color.rgb, i_color.a * inv_n);
124    f_emission   = i_emission * inv_n;
125    f_glow_color = i_glow_color;
126    f_glow_radius = i_glow_radius;
127    f_fill        = i_flags.x;
128}
129"#;
130
131/// Glyph fragment shader -- SDF rendering with outline, glow halo, and drop shadow.
132///
133/// The atlas stores Signed Distance Field values:
134///   128/255 = edge, >128 = inside, <128 = outside.
135///
136/// `o_color`    -> COLOR_ATTACHMENT0 -- blended scene color
137/// `o_emission` -> COLOR_ATTACHMENT1 -- bloom input
138const FRAG_SRC: &str = r#"
139#version 330 core
140
141in vec2  f_uv;
142in vec4  f_color;
143in float f_emission;
144in vec3  f_glow_color;
145in float f_glow_radius;
146in float f_fill;
147
148uniform sampler2D u_atlas;
149
150layout(location = 0) out vec4 o_color;
151layout(location = 1) out vec4 o_emission;
152// Coverage of matter, for the light map's shadows. Fills write none, and
153// cover whatever was under them.
154layout(location = 2) out vec4 o_occluder;
155
156void main() {
157    float dist = texture(u_atlas, f_uv).r;
158
159    // SDF thresholds (in normalized distance, 0.5 = edge)
160    float edge     = 0.5;
161    float softness = 0.08; // edge softness (antialiasing)
162
163    // Core glyph alpha (sharp edge with antialiasing)
164    float alpha = smoothstep(edge - softness, edge + softness, dist);
165
166    // Outline: a band just outside the edge
167    float outline_width = 0.06;
168    float outline_alpha = smoothstep(edge - outline_width - softness, edge - outline_width, dist)
169                        * (1.0 - smoothstep(edge - softness * 0.5, edge + softness * 0.5, dist));
170    vec3 outline_color = f_glow_color * 0.6;
171
172    // Glow halo: soft falloff outside the glyph
173    float glow_size  = 0.15 + f_glow_radius * 0.05;
174    float glow_alpha = smoothstep(edge - glow_size - 0.1, edge - 0.02, dist) * (1.0 - alpha);
175    glow_alpha *= clamp(f_glow_radius * 0.3, 0.0, 0.5);
176
177    // Drop shadow (offset sample)
178    vec2 shadow_offset = vec2(0.003, -0.004);
179    float shadow_dist = texture(u_atlas, f_uv + shadow_offset).r;
180    float shadow_alpha = smoothstep(edge - softness, edge + softness, shadow_dist) * 0.2;
181
182    // Discard if nothing visible
183    float total_alpha = max(max(alpha, outline_alpha), max(glow_alpha, shadow_alpha));
184    if (total_alpha < 0.01) discard;
185
186    // Composite layers
187    float em = clamp(f_emission * 0.5, 0.0, 1.0);
188    vec3 base_col = mix(f_color.rgb, f_glow_color, em);
189
190    // Shadow (darkest layer)
191    vec3 col = vec3(0.0);
192    float a = shadow_alpha * f_color.a * 0.3;
193
194    // Glow halo (behind outline)
195    col = mix(col, f_glow_color * 0.5, glow_alpha);
196    a = max(a, glow_alpha * f_color.a * 0.4);
197
198    // Outline
199    col = mix(col, outline_color, outline_alpha);
200    a = max(a, outline_alpha * f_color.a * 0.7);
201
202    // Core fill (on top)
203    col = mix(col, base_col, alpha);
204    a = max(a, alpha * f_color.a);
205
206    o_color = vec4(col, a);
207
208    // Emission for bloom
209    float bloom_strength = clamp(f_emission - 0.3, 0.0, 1.0);
210    float glow_boost = clamp(f_glow_radius * 0.15, 0.0, 0.8);
211    float em_alpha = max(alpha, glow_alpha * 0.5) * f_color.a;
212    o_emission = vec4(f_glow_color * (bloom_strength + glow_boost), em_alpha);
213    o_occluder = vec4(alpha * f_color.a * (1.0 - f_fill), 0.0, 0.0, a);
214}
215"#;
216
217// ── Unit quad geometry ─────────────────────────────────────────────────────────
218
219/// Unit quad: 6 vertices (2 CCW triangles), each: [pos_x, pos_y, uv_x, uv_y]
220#[rustfmt::skip]
221const QUAD_VERTS: [f32; 24] = [
222    -0.5,  0.5,  0.0, 1.0,
223    -0.5, -0.5,  0.0, 0.0,
224     0.5,  0.5,  1.0, 1.0,
225    -0.5, -0.5,  0.0, 0.0,
226     0.5, -0.5,  1.0, 0.0,
227     0.5,  0.5,  1.0, 1.0,
228];
229
230// ── FrameStats ─────────────────────────────────────────────────────────────────
231
232/// Per-frame rendering statistics.
233#[derive(Clone, Debug, Default)]
234pub struct FrameStats {
235    /// Frames per second (rolling average over 60 frames).
236    pub fps:              f32,
237    /// Time of last frame in seconds.
238    pub dt:               f32,
239    /// Number of glyphs drawn this frame.
240    pub glyph_count:      usize,
241    /// Number of particles drawn this frame.
242    pub particle_count:   usize,
243    /// Number of draw calls this frame.
244    pub draw_calls:       u32,
245    /// Total frame number since engine start.
246    pub frame_number:     u64,
247}
248
249/// Rolling FPS calculator over N frames.
250struct FpsCounter {
251    samples:   [f32; 60],
252    head:      usize,
253    filled:    bool,
254}
255
256impl FpsCounter {
257    fn new() -> Self { Self { samples: [0.016; 60], head: 0, filled: false } }
258
259    fn push(&mut self, dt: f32) {
260        self.samples[self.head] = dt.max(f32::EPSILON);
261        self.head = (self.head + 1) % 60;
262        if self.head == 0 { self.filled = true; }
263    }
264
265    fn fps(&self) -> f32 {
266        let count = if self.filled { 60 } else { self.head.max(1) };
267        let avg_dt: f32 = self.samples[..count].iter().sum::<f32>() / count as f32;
268        1.0 / avg_dt
269    }
270}
271
272// ── Pipeline ───────────────────────────────────────────────────────────────────
273
274/// The main render pipeline.
275///
276/// Created once by `ProofEngine::new()` and kept alive for the duration of the game.
277/// Owns the window, OpenGL context, shader programs, font atlas, glyph VAO, and
278/// the post-processing pipeline.
279#[allow(dead_code)]
280pub struct Pipeline {
281    // ── Runtime info ──────────────────────────────────────────────────────────
282    pub width:   u32,
283    pub height:  u32,
284    pub stats:   FrameStats,
285    running:     bool,
286
287    // ── Config snapshot (not a reference — the engine owns EngineConfig) ──────
288    render_config: RenderConfig,
289
290    // ── Windowing ────────────────────────────────────────────────────────────
291    event_loop: EventLoop<()>,
292    window:     Window,
293    surface:    Surface<WindowSurface>,
294    context:    PossiblyCurrentContext,
295
296    // ── OpenGL glyph pass ─────────────────────────────────────────────────────
297    gl:            glow::Context,
298    program:       glow::Program,
299    vao:           glow::VertexArray,
300    quad_vbo:      glow::Buffer,
301    instance_vbo:  glow::Buffer,
302    atlas_tex:     glow::Texture,
303    loc_view_proj: glow::UniformLocation,
304    loc_n_copies:  Option<glow::UniformLocation>,
305
306    // ── Post-processing pipeline (the real deal — reads RenderConfig) ─────────
307    postfx: PostFxPipeline,
308
309    // ── Font atlas ────────────────────────────────────────────────────────────
310    atlas: FontAtlas,
311
312    // ── Screen-space UI pass ──────────────────────────────────────────────────
313    // Builds instances from a UiLayer's draw queue and paints them in pixel
314    // coordinates after post-processing, so panels and text stay crisp.
315    ui_renderer: super::ui_layer_renderer::UiLayerRenderer,
316    /// Whether `render_frame` already built this frame's UI instances, so
317    /// `render_ui` need not build them again.
318    ui_prepared: bool,
319
320    // ── GPU density entities ──────────────────────────────────────────────────
321    /// Created the first time an entity is queued.
322    density: Option<GpuDensityRenderer>,
323    density_entities: Vec<GpuDensityEntityData>,
324    density_budget: u32,
325
326    // ── SVOGI Global Illumination ───────────────────────────────────────────
327    pub svogi: crate::svogi::integration::CascadedSvogi,
328
329    // ── Volumetric fog ──────────────────────────────────────────────────────
330    pub fog: crate::volumetric_fog::VolumetricFogPipeline,
331
332    // ── CPU-side glyph batch ──────────────────────────────────────────────────
333    instances: Vec<GlyphInstance>,
334
335    // ── Timing ────────────────────────────────────────────────────────────────
336    fps_counter:  FpsCounter,
337    frame_start:  Instant,
338    scene_time:   f32,
339
340    // ── Mouse state ───────────────────────────────────────────────────────────
341    mouse_pos:      Vec2,
342    mouse_pos_prev: Vec2,
343    /// Normalized device coordinates (NDC) of the mouse cursor.
344    mouse_ndc:      Vec2,
345
346    // ── Raw events for external consumers (egui) ────────────────────────────
347    /// Raw winit WindowEvents collected during poll_events, drained by consumers.
348    pub raw_window_events: Vec<winit::event::WindowEvent>,
349}
350
351/// Print why no window could be opened and exit with status 1.
352fn no_display(err: &dyn std::fmt::Display) -> ! {
353    eprintln!("proof-engine: cannot open a window: {err}");
354    eprintln!("It needs a desktop session (on Linux, X11 or Wayland with DISPLAY or WAYLAND_DISPLAY set) and a GPU with OpenGL 3.3 or newer.");
355    std::process::exit(1);
356}
357
358impl Pipeline {
359    /// Initialize window, OpenGL 3.3 Core context, shader programs, font atlas, and PostFxPipeline.
360    pub fn init(config: &EngineConfig) -> Self {
361        // ── 1. winit EventLoop ────────────────────────────────────────────────
362        // No display (a Linux box without X11 or Wayland, a CI runner, an
363        // SSH session) is an ordinary situation, not a bug: say so and exit
364        // instead of panicking with a backtrace.
365        let event_loop = match EventLoop::new() {
366            Ok(el) => el,
367            Err(e) => no_display(&e),
368        };
369
370        // ── 2. Window attributes (winit 0.30 API) ─────────────────────────────
371        let window_attrs = Window::default_attributes()
372            .with_title(&config.window_title)
373            .with_inner_size(LogicalSize::new(config.window_width, config.window_height))
374            .with_resizable(true)
375            // PROOF_HIDDEN: capture frames without a window appearing or
376            // taking focus from whatever the person at the machine is doing.
377            .with_visible(!crate::capture::hidden_window())
378            .with_active(!crate::capture::hidden_window());
379
380        // ── 3. GL config via DisplayBuilder (glutin-winit 0.5) ────────────────
381        let template = ConfigTemplateBuilder::new()
382            .with_alpha_size(8)
383            .with_depth_size(0);
384
385        let display_builder = DisplayBuilder::new()
386            .with_window_attributes(Some(window_attrs));
387
388        let (window, gl_config) = display_builder
389            .build(&event_loop, template, |mut configs| {
390                configs.next().expect("no suitable GL config found")
391            })
392            .unwrap_or_else(|e| no_display(&e));
393
394        let window = window.expect("window was not created");
395        let display = gl_config.display();
396
397        // ── 4. OpenGL context — try 4.3 (compute shaders) then fall back to 3.3 ─
398        let raw_handle = window.window_handle().unwrap().as_raw();
399        let ctx_attrs_43 = ContextAttributesBuilder::new()
400            .with_context_api(ContextApi::OpenGl(Some(Version::new(4, 3))))
401            .build(Some(raw_handle));
402        let ctx_attrs_33 = ContextAttributesBuilder::new()
403            .with_context_api(ContextApi::OpenGl(Some(Version::new(3, 3))))
404            .build(Some(raw_handle));
405
406        let not_current = unsafe {
407            display.create_context(&gl_config, &ctx_attrs_43)
408                   .unwrap_or_else(|_| {
409                       display.create_context(&gl_config, &ctx_attrs_33)
410                              .expect("create_context failed (both GL 4.3 and 3.3)")
411                   })
412        };
413
414        // ── 5. Window surface ─────────────────────────────────────────────────
415        let size = window.inner_size();
416        let w = size.width.max(1);
417        let h = size.height.max(1);
418
419        let surface_attrs = window
420            .build_surface_attributes(Default::default())
421            .expect("build_surface_attributes failed");
422
423        let surface = unsafe {
424            display.create_window_surface(&gl_config, &surface_attrs)
425                   .expect("create_window_surface failed")
426        };
427
428        // ── 6. Make current ───────────────────────────────────────────────────
429        let context = not_current.make_current(&surface)
430                                 .expect("make_current failed");
431        if config.render.vsync {
432            use glutin::surface::SwapInterval;
433            if let Err(e) = surface.set_swap_interval(
434                &context,
435                SwapInterval::Wait(NonZeroU32::new(1).unwrap()),
436            ) {
437                log::warn!("vsync unavailable: {e}");
438            }
439        }
440
441        // ── 7. glow context from proc address ─────────────────────────────────
442        let gl = unsafe {
443            glow::Context::from_loader_function(|sym| {
444                let sym_c = CString::new(sym).unwrap();
445                display.get_proc_address(sym_c.as_c_str()) as *const _
446            })
447        };
448
449        // ── 8. Compile glyph program ──────────────────────────────────────────
450        let program = unsafe { compile_program(&gl, VERT_SRC, FRAG_SRC) };
451        let loc_view_proj = unsafe {
452            gl.get_uniform_location(program, "u_view_proj")
453              .expect("uniform u_view_proj not found")
454        };
455        let loc_n_copies = unsafe {
456            gl.get_uniform_location(program, "u_n_copies")
457        };
458        unsafe {
459            gl.use_program(Some(program));
460            if let Some(loc) = gl.get_uniform_location(program, "u_atlas") {
461                gl.uniform_1_i32(Some(&loc), 0);
462            }
463            // Default: no oversampling
464            if let Some(ref loc) = loc_n_copies {
465                gl.uniform_1_u32(Some(loc), 1u32);
466            }
467        }
468
469        // ── 9. Geometry: VAO + VBOs ───────────────────────────────────────────
470        let (vao, quad_vbo, instance_vbo) = unsafe { setup_vao(&gl) };
471
472        // ── 10. Font atlas ────────────────────────────────────────────────────
473        let atlas     = FontAtlas::build(config.render.font_size as f32);
474        let atlas_tex = unsafe { upload_atlas(&gl, &atlas) };
475
476        // ── 11. PostFxPipeline — dual-attachment FBOs + bloom shaders ────────
477        let postfx = unsafe { PostFxPipeline::new(&gl, w, h, config.render.render_scale) };
478
479        // ── 12. Global GL state ───────────────────────────────────────────────
480        unsafe {
481            gl.enable(glow::BLEND);
482            gl.blend_func(glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA);
483            gl.clear_color(0.02, 0.02, 0.05, 1.0);
484            gl.viewport(0, 0, w as i32, h as i32);
485        }
486
487        log::info!(
488            "Pipeline ready — {}×{} — font atlas {}×{} ({} chars) — PostFxPipeline wired",
489            w, h, atlas.width, atlas.height, atlas.uvs.len()
490        );
491
492        Self {
493            width: w, height: h,
494            stats: FrameStats::default(),
495            running: true,
496            render_config: config.render.clone(),
497            event_loop, window, surface, context,
498            gl, program, vao, quad_vbo, instance_vbo, atlas_tex, loc_view_proj, loc_n_copies,
499            postfx,
500            atlas,
501            ui_renderer: super::ui_layer_renderer::UiLayerRenderer::new(),
502            ui_prepared: false,
503            density: None,
504            density_entities: Vec::new(),
505            density_budget: 0,
506            instances: Vec::with_capacity(8192),
507            fps_counter: FpsCounter::new(),
508            frame_start: Instant::now(),
509            scene_time: 0.0,
510            mouse_pos: Vec2::ZERO,
511            mouse_pos_prev: Vec2::ZERO,
512            mouse_ndc: Vec2::ZERO,
513            raw_window_events: Vec::new(),
514            svogi: crate::svogi::integration::CascadedSvogi::new(3, 64, 50.0),
515            fog: crate::volumetric_fog::VolumetricFogPipeline::new(
516                crate::volumetric_fog::FogPresets::combat()
517            ),
518        }
519    }
520
521    /// Hand over this frame's GPU density entities and the particle budget
522    /// each is drawn with. They are drawn into the scene after the glyph
523    /// pass, so they bloom and grade with everything else.
524    pub fn set_density_entities(&mut self, entities: &[GpuDensityEntityData], budget: u32) {
525        self.density_entities.clear();
526        self.density_entities.extend_from_slice(entities);
527        self.density_budget = budget;
528        if !self.density_entities.is_empty() && self.density.is_none() {
529            self.density = Some(unsafe { GpuDensityRenderer::new(&self.gl) });
530        }
531    }
532
533    /// Update the render config used by the PostFx pipeline this frame.
534    /// Call from `ProofEngine::run()` whenever the config changes.
535    pub fn update_render_config(&mut self, config: &RenderConfig) {
536        let old_scale = self.render_config.render_scale;
537        self.render_config = config.clone();
538        // A change of render scale is a change of target size.
539        if (config.render_scale - old_scale).abs() > 1e-4 {
540            unsafe { self.postfx.resize(&self.gl, self.width, self.height, config.render_scale); }
541        }
542    }
543
544    /// Poll window events and update `InputState`. Returns false on quit.
545    pub fn poll_events(&mut self, input: &mut InputState) -> bool {
546        input.clear_frame();
547        self.mouse_pos_prev = self.mouse_pos;
548
549        let mut should_exit = false;
550        let mut resize:     Option<(u32, u32)>  = None;
551        let mut key_events: Vec<(KeyCode, bool)> = Vec::new();
552        let mut mouse_moved:     Option<(f64, f64)> = None;
553        let mut mouse_buttons:   Vec<(MouseButton, bool)> = Vec::new();
554        let mut scroll_delta:    f32 = 0.0;
555
556        self.raw_window_events.clear();
557
558        #[allow(deprecated)]
559        let status = self.event_loop.pump_events(Some(Duration::ZERO), |event, elwt| {
560            match event {
561                Event::WindowEvent { event: we, .. } => match we {
562                    WindowEvent::CloseRequested => {
563                        should_exit = true;
564                        elwt.exit();
565                    }
566                    WindowEvent::Resized(s) => {
567                        resize = Some((s.width, s.height));
568                    }
569                    WindowEvent::KeyboardInput { event: key_ev, .. } => {
570                        if let PhysicalKey::Code(kc) = key_ev.physical_key {
571                            let pressed = key_ev.state == ElementState::Pressed;
572                            key_events.push((kc, pressed));
573                        }
574                    }
575                    WindowEvent::CursorMoved { position, .. } => {
576                        mouse_moved = Some((position.x, position.y));
577                    }
578                    WindowEvent::MouseInput { button, state, .. } => {
579                        let pressed = state == ElementState::Pressed;
580                        mouse_buttons.push((button, pressed));
581                    }
582                    WindowEvent::MouseWheel { delta, .. } => {
583                        scroll_delta += match delta {
584                            MouseScrollDelta::LineDelta(_, y) => y,
585                            MouseScrollDelta::PixelDelta(d)   => d.y as f32 / 40.0,
586                        };
587                    }
588                    _ => {}
589                }
590                _ => {}
591            }
592        });
593
594        // ── Apply resize ───────────────────────────────────────────────────────
595        if let Some((w, h)) = resize {
596            if w > 0 && h > 0 {
597                self.surface.resize(
598                    &self.context,
599                    NonZeroU32::new(w).unwrap(),
600                    NonZeroU32::new(h).unwrap(),
601                );
602                unsafe { self.gl.viewport(0, 0, w as i32, h as i32); }
603                self.width  = w;
604                self.height = h;
605                input.window_resized = Some((w, h));
606                unsafe { self.postfx.resize(&self.gl, w, h, self.render_config.render_scale); }
607            }
608        }
609
610        // ── Apply key events ───────────────────────────────────────────────────
611        for (kc, pressed) in key_events {
612            if let Some(key) = keycode_to_engine(kc) {
613                if pressed {
614                    input.keys_pressed.insert(key);
615                    input.keys_just_pressed.insert(key);
616                } else {
617                    input.keys_pressed.remove(&key);
618                    input.keys_just_released.insert(key);
619                }
620            }
621        }
622
623        // ── Apply mouse events ─────────────────────────────────────────────────
624        if let Some((x, y)) = mouse_moved {
625            self.mouse_pos = Vec2::new(x as f32, y as f32);
626            input.mouse_x = x as f32;
627            input.mouse_y = y as f32;
628            // Compute NDC: x/y ∈ [0, width/height] → [-1, 1]
629            let w = self.width.max(1) as f32;
630            let h = self.height.max(1) as f32;
631            self.mouse_ndc = Vec2::new(
632                (x as f32 / w) * 2.0 - 1.0,
633                1.0 - (y as f32 / h) * 2.0,
634            );
635            input.mouse_ndc = self.mouse_ndc;
636            input.mouse_delta = self.mouse_pos - self.mouse_pos_prev;
637        }
638
639        for (button, pressed) in mouse_buttons {
640            match button {
641                MouseButton::Left   => {
642                    if pressed { input.mouse_left_just_pressed  = true; }
643                    else       { input.mouse_left_just_released = true; }
644                    input.mouse_left = pressed;
645                }
646                MouseButton::Right  => {
647                    if pressed { input.mouse_right_just_pressed  = true; }
648                    else       { input.mouse_right_just_released = true; }
649                    input.mouse_right = pressed;
650                }
651                MouseButton::Middle => {
652                    if pressed { input.mouse_middle_just_pressed = true; }
653                    input.mouse_middle = pressed;
654                }
655                _ => {}
656            }
657        }
658
659        input.scroll_delta = scroll_delta;
660
661        // ── Exit check ─────────────────────────────────────────────────────────
662        if should_exit || matches!(status, PumpStatus::Exit(_)) {
663            self.running = false;
664        }
665        self.running
666    }
667
668    /// Collect all visible glyphs + particles from the scene, upload to the GPU,
669    /// and execute the full multi-pass rendering pipeline.
670    pub fn render(&mut self, scene: &Scene, camera: &ProofCamera) {
671        let fx = ScreenFx::default();
672        self.render_frame(scene, camera, None, &fx);
673    }
674
675    /// Render the scene, the UI layer's world pass, and post-processing.
676    ///
677    /// With `ui` given, its world-pass commands are painted into the HDR
678    /// scene buffer after the 3D glyphs and before post-processing, so they
679    /// bloom, grade and shake with the scene; its HUD-pass commands are
680    /// built here and painted by [`render_ui`](Self::render_ui) afterwards.
681    /// `fx` supplies the frame's shockwaves, flash and light-shaft source.
682    pub fn render_frame(
683        &mut self,
684        scene: &Scene,
685        camera: &ProofCamera,
686        ui: Option<&UiLayer>,
687        fx: &ScreenFx,
688    ) {
689        // ── Frame timing ───────────────────────────────────────────────────────
690        let now = Instant::now();
691        let dt  = now.duration_since(self.frame_start).as_secs_f32().min(0.1);
692        self.frame_start = now;
693        self.scene_time  = scene.time;
694
695        self.fps_counter.push(dt);
696        self.stats.fps          = self.fps_counter.fps();
697        self.stats.dt           = dt;
698        self.stats.frame_number += 1;
699
700        // ── Build camera matrices ──────────────────────────────────────────────
701        let pos    = camera.position.position();
702        let tgt    = camera.target.position();
703        let fov    = camera.fov.position;
704        let aspect = if self.height > 0 { self.width as f32 / self.height as f32 } else { 1.0 };
705        let view      = Mat4::look_at_rh(pos, tgt, Vec3::Y);
706        let proj      = Mat4::perspective_rh_gl(fov.to_radians(), aspect, camera.near, camera.far);
707        let view_proj = proj * view;
708
709        // ── Update SVOGI (voxelize scene, inject light, propagate) ───────────
710        if self.render_config.global_illumination {
711            use crate::svogi::inject::{LightSource, DirectionalLight, PointLight};
712            let sun = LightSource::Directional(DirectionalLight {
713                direction: Vec3::new(-0.5, 1.0, 0.8).normalize(),
714                color: Vec3::new(0.8, 0.75, 0.65),
715                intensity: 1.0,
716            });
717            let mut all_lights = vec![sun];
718            // Inject emissive glyphs as point lights
719            let mut emissive_count = 0;
720            for (_, glyph) in scene.glyphs.iter() {
721                if glyph.emission > 0.5 && emissive_count < 50 {
722                    all_lights.push(LightSource::Point(PointLight {
723                        position: glyph.position,
724                        color: glyph.glow_color,
725                        intensity: glyph.emission * 0.3,
726                        radius: glyph.glow_radius * 2.0,
727                    }));
728                    emissive_count += 1;
729                }
730            }
731            self.svogi.update(dt, &[], &all_lights, &[]);
732        }
733
734        // ── Update volumetric fog ─────────────────────────────────────────────
735        if self.render_config.volumetric_fog {
736            use crate::volumetric_fog::FogLight;
737            let inv_vp = view_proj.inverse();
738            let fog_lights = vec![
739                FogLight::Directional {
740                    direction: Vec3::new(-0.3, -0.8, -0.5).normalize(),
741                    color: Vec3::new(0.6, 0.55, 0.5),
742                    intensity: 0.5,
743                },
744            ];
745            self.fog.update(dt, &inv_vp, pos, &fog_lights, &[]);
746        }
747
748        // ── Build glyph batch ──────────────────────────────────────────────────
749        self.instances.clear();
750        let mut glyph_count    = 0;
751        let mut particle_count = 0;
752
753        // Glyphs sorted by render layer (entity < particle < UI)
754        for (_, glyph) in scene.glyphs.iter() {
755            if !glyph.visible { continue; }
756            let life_scale = if let Some(ref f) = glyph.life_function {
757                f.evaluate(scene.time, 0.0)
758            } else {
759                1.0
760            };
761            let uv = self.atlas.uv_for(glyph.character);
762
763            // Apply distance fog: glyphs further from camera fade toward fog color
764            let dist = (glyph.position - pos).length();
765            let fog_density = 0.003; // very subtle
766            let fog_factor = (-dist * fog_density).exp(); // 1.0 = no fog, 0.0 = fully fogged
767            let fog_color = [0.03f32, 0.04, 0.06]; // dark blue-ish fog
768            let mut color = glyph.color.to_array();
769            color[0] = color[0] * fog_factor + fog_color[0] * (1.0 - fog_factor);
770            color[1] = color[1] * fog_factor + fog_color[1] * (1.0 - fog_factor);
771            color[2] = color[2] * fog_factor + fog_color[2] * (1.0 - fog_factor);
772            color[3] *= fog_factor.max(0.1); // alpha also fades but not to zero
773            let emission_fogged = glyph.emission * fog_factor;
774
775            self.instances.push(GlyphInstance {
776                position:    glyph.position.to_array(),
777                scale:       [glyph.scale.x * life_scale, glyph.scale.y * life_scale],
778                rotation:    glyph.rotation,
779                color,
780                emission:    emission_fogged,
781                glow_color:  glyph.glow_color.to_array(),
782                glow_radius: glyph.glow_radius * fog_factor,
783                uv_offset:   uv.offset(),
784                uv_size:     uv.size(),
785                _pad:        [0.0; 2],
786            });
787            glyph_count += 1;
788        }
789
790        for particle in scene.particles.iter() {
791            let g = &particle.glyph;
792            if !g.visible { continue; }
793            let uv = self.atlas.uv_for(g.character);
794            // Apply fog to particles too
795            let dist = (g.position - pos).length();
796            let fog_factor = (-dist * 0.003).exp();
797            let fog_color = [0.03f32, 0.04, 0.06];
798            let mut color = g.color.to_array();
799            color[0] = color[0] * fog_factor + fog_color[0] * (1.0 - fog_factor);
800            color[1] = color[1] * fog_factor + fog_color[1] * (1.0 - fog_factor);
801            color[2] = color[2] * fog_factor + fog_color[2] * (1.0 - fog_factor);
802            color[3] *= fog_factor.max(0.1);
803
804            self.instances.push(GlyphInstance {
805                position:    g.position.to_array(),
806                scale:       [g.scale.x, g.scale.y],
807                rotation:    g.rotation,
808                color,
809                emission:    g.emission * fog_factor,
810                glow_color:  g.glow_color.to_array(),
811                glow_radius: g.glow_radius * fog_factor,
812                uv_offset:   uv.offset(),
813                uv_size:     uv.size(),
814                _pad:        [0.0; 2],
815            });
816            particle_count += 1;
817        }
818
819        self.stats.glyph_count    = glyph_count;
820        self.stats.particle_count = particle_count;
821        self.stats.draw_calls     = 0;
822
823        // ── World-pass UI ──────────────────────────────────────────────────────
824        //
825        // Built once here for both passes. The world pass is projected in
826        // screen pixels like the HUD, then shifted by the camera's trauma so
827        // a blow moves the world and not the interface.
828        let mut world_proj = None;
829        if let Some(ui) = ui {
830            self.ui_renderer.build_instances(ui, &self.atlas);
831            self.ui_prepared = true;
832            if self.render_config.world_ui_in_scene && self.ui_renderer.world_count() > 0 {
833                let trauma = camera.shake.trauma.clamp(0.0, 1.0);
834                let amp = trauma * trauma * self.render_config.shake_pixels;
835                let t = self.scene_time;
836                let shake = Vec3::new((t * 47.3).sin() * amp, (t * 31.7).cos() * amp, 0.0);
837                world_proj = Some(ui.world_projection() * Mat4::from_translation(shake));
838            }
839        }
840
841        // ── Execute render passes ──────────────────────────────────────────────
842        unsafe { self.execute_render_passes(view_proj, world_proj, fx); }
843    }
844
845    /// Paint a screen-space UI layer on top of the finished frame.
846    ///
847    /// Runs after post-processing and writes straight to the default
848    /// framebuffer, so panels and text are not smeared by bloom, chromatic
849    /// aberration or grain — a HUD has to stay readable.
850    ///
851    /// Call once per frame, after `render`, before `swap`.
852    pub fn render_ui(&mut self, ui: &super::ui_layer::UiLayer) {
853        let prepared = std::mem::replace(&mut self.ui_prepared, false);
854        if ui.command_count() == 0 {
855            return;
856        }
857        if !prepared {
858            self.ui_renderer.build_instances(ui, &self.atlas);
859        }
860        let proj = ui.projection();
861        // With the world pass switched off, its commands paint here instead,
862        // under the HUD, which is how the layer behaved before it existed.
863        if !self.render_config.world_ui_in_scene && self.ui_renderer.world_count() > 0 {
864            unsafe { self.draw_ui_pass(proj, true) };
865            self.stats.draw_calls += 1;
866        }
867        if self.ui_renderer.glyph_count() == 0 {
868            return;
869        }
870        unsafe { self.draw_ui_pass(proj, false) };
871        self.stats.draw_calls += 1;
872    }
873
874    /// Upload and draw one of the UI instance buffers, straight to the
875    /// screen, with an orthographic projection. `world` picks the world-pass
876    /// buffer; otherwise the HUD buffer.
877    unsafe fn draw_ui_pass(&mut self, proj: Mat4, world: bool) {
878        let gl = &self.gl;
879        let (bytes, count) = if world {
880            (self.ui_renderer.world_bytes(), self.ui_renderer.world_count())
881        } else {
882            (self.ui_renderer.glyph_bytes(), self.ui_renderer.glyph_count())
883        };
884
885        // Straight to the screen: post-processing has already composited.
886        gl.bind_framebuffer(glow::FRAMEBUFFER, None);
887        gl.viewport(0, 0, self.width as i32, self.height as i32);
888
889        // UI is 2D and ordered by draw call, so depth testing would only cause
890        // z-fighting between overlapping panels.
891        gl.disable(glow::DEPTH_TEST);
892        gl.enable(glow::BLEND);
893        gl.blend_func(glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA);
894
895        gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.instance_vbo));
896        gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, bytes, glow::DYNAMIC_DRAW);
897
898        gl.use_program(Some(self.program));
899        gl.uniform_matrix_4_f32_slice(Some(&self.loc_view_proj), false, &proj.to_cols_array());
900        // The oversampling trick used by the 3D pass would smear UI text, so
901        // the UI always draws exactly one copy per instance.
902        gl.uniform_1_u32(self.loc_n_copies.as_ref(), 1);
903        gl.active_texture(glow::TEXTURE0);
904        gl.bind_texture(glow::TEXTURE_2D, Some(self.atlas_tex));
905        gl.bind_vertex_array(Some(self.vao));
906        for loc in 2u32..=11 {
907            gl.vertex_attrib_divisor(loc, 1);
908        }
909        gl.draw_arrays_instanced(glow::TRIANGLES, 0, 6, count as i32);
910
911        // Restore state the 3D pass expects on the next frame.
912        gl.enable(glow::DEPTH_TEST);
913    }
914
915    /// Swap back buffer to screen. Returns false on window close.
916    pub fn swap(&mut self) -> bool {
917        if let Err(e) = self.surface.swap_buffers(&self.context) {
918            log::error!("swap_buffers failed: {e}");
919            self.running = false;
920        }
921        self.running
922    }
923
924    // ── Public accessors for editor/egui integration ──────────────────────────
925
926    /// Get a reference to the raw glow OpenGL context.
927    /// Used by egui-glow to render UI on top of the scene.
928    pub fn gl(&self) -> &glow::Context {
929        &self.gl
930    }
931
932    /// Get the window reference (for egui-winit event processing).
933    pub fn window(&self) -> &Window {
934        &self.window
935    }
936
937    /// The framebuffer size the viewport is actually set to.
938    ///
939    /// This is what screen-space passes must project against. On a scaled
940    /// display it can differ from the window's logical size, and projecting
941    /// against the wrong one magnifies the whole UI.
942    pub fn render_size(&self) -> (u32, u32) {
943        (self.width, self.height)
944    }
945
946    /// Read the frame that is currently on screen back off the GPU.
947    ///
948    /// Returns `(width, height, rgba)` with the bottom row first, which is how
949    /// OpenGL stores it. Call this after everything for the frame has been
950    /// drawn and before the buffers are swapped, or the read comes back empty.
951    pub fn read_frame(&self) -> (u32, u32, Vec<u8>) {
952        let (w, h) = (self.width, self.height);
953        let mut buf = vec![0u8; (w as usize) * (h as usize) * 4];
954        unsafe {
955            let gl = &self.gl;
956            gl.bind_framebuffer(glow::FRAMEBUFFER, None);
957            gl.read_buffer(glow::BACK);
958            gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
959            gl.read_pixels(
960                0,
961                0,
962                w as i32,
963                h as i32,
964                glow::RGBA,
965                glow::UNSIGNED_BYTE,
966                glow::PixelPackData::Slice(Some(&mut buf)),
967            );
968        }
969        (w, h, buf)
970    }
971
972    /// Get the current window size.
973    pub fn window_size(&self) -> (u32, u32) {
974        let size = self.window.inner_size();
975        (size.width, size.height)
976    }
977
978    // ── Private render pass execution ─────────────────────────────────────────
979
980    unsafe fn execute_render_passes(
981        &mut self,
982        view_proj: Mat4,
983        world_proj: Option<Mat4>,
984        fx: &ScreenFx,
985    ) {
986        let gl = &self.gl;
987
988        // ── Pass 0: clear the HDR scene targets ────────────────────────────────
989        //
990        // At render scale, which may differ from the window. A dark
991        // blue-black ground rather than pure black, so the vignette and the
992        // dither have something to work against.
993        let (sw, sh) = self.postfx.scene_size();
994        gl.bind_framebuffer(glow::FRAMEBUFFER, Some(self.postfx.scene_fbo));
995        gl.viewport(0, 0, sw as i32, sh as i32);
996        gl.clear_color(0.02, 0.025, 0.04, 1.0);
997        gl.clear(glow::COLOR_BUFFER_BIT);
998        // The occluder buffer starts empty rather than at the ground colour.
999        gl.clear_buffer_f32_slice(glow::COLOR, 2, &[0.0, 0.0, 0.0, 0.0]);
1000        gl.enable(glow::BLEND);
1001        gl.blend_func(glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA);
1002
1003        // ── Pass 1: Render glyphs ────────────────────────────────────────────
1004
1005        if !self.instances.is_empty() {
1006            // Upload instance data
1007            gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.instance_vbo));
1008            gl.buffer_data_u8_slice(
1009                glow::ARRAY_BUFFER,
1010                cast_slice(self.instances.as_slice()),
1011                glow::DYNAMIC_DRAW,
1012            );
1013
1014            // Oversampling: each base instance is rendered n_copies times.
1015            // vertex_attrib_divisor = n_copies → all n_copies share the same
1016            // attribute data. The vertex shader uses gl_InstanceID to derive
1017            // the copy index and compute per-copy position jitter.
1018            let n_copies = self.render_config.particle_multiplier
1019                .ceil().max(1.0) as u32;
1020
1021            gl.use_program(Some(self.program));
1022            gl.uniform_matrix_4_f32_slice(
1023                Some(&self.loc_view_proj),
1024                false,
1025                &view_proj.to_cols_array(),
1026            );
1027            gl.uniform_1_u32(self.loc_n_copies.as_ref(), n_copies);
1028            gl.active_texture(glow::TEXTURE0);
1029            gl.bind_texture(glow::TEXTURE_2D, Some(self.atlas_tex));
1030            gl.bind_vertex_array(Some(self.vao));
1031            // Set attribute divisor to n_copies so each base instance repeats
1032            // n_copies times before advancing to the next instance in the VBO.
1033            for loc in 2u32..=11 {
1034                gl.vertex_attrib_divisor(loc, n_copies);
1035            }
1036            gl.draw_arrays_instanced(
1037                glow::TRIANGLES, 0, 6,
1038                (self.instances.len() as u32 * n_copies) as i32,
1039            );
1040            self.stats.draw_calls += 1;
1041        }
1042
1043        // ── Pass 1a: GPU density entities ──────────────────────────────────────
1044        //
1045        // Millions of particles derived on the GPU from a few bones. Into
1046        // the same HDR targets, before the world pass so screen-space matter
1047        // can stand in front of them.
1048        if let Some(ref mut density) = self.density {
1049            if !self.density_entities.is_empty() {
1050                let draws = density.draw(
1051                    gl,
1052                    &self.density_entities,
1053                    self.density_budget,
1054                    &view_proj,
1055                    (sw, sh),
1056                    self.scene_time,
1057                );
1058                self.stats.draw_calls += draws;
1059                self.stats.particle_count += density.drawn as usize;
1060            }
1061        }
1062
1063        // ── Pass 1b: the UI layer's world pass ─────────────────────────────────
1064        //
1065        // Same program, same instance layout, projected in screen pixels
1066        // rather than through the camera, into the same HDR targets. From
1067        // here on the post-processing cannot tell it from the 3D scene.
1068        if let Some(wp) = world_proj {
1069            let count = self.ui_renderer.world_count();
1070            if count > 0 {
1071                gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.instance_vbo));
1072                gl.buffer_data_u8_slice(
1073                    glow::ARRAY_BUFFER,
1074                    self.ui_renderer.world_bytes(),
1075                    glow::DYNAMIC_DRAW,
1076                );
1077                gl.use_program(Some(self.program));
1078                gl.uniform_matrix_4_f32_slice(
1079                    Some(&self.loc_view_proj),
1080                    false,
1081                    &wp.to_cols_array(),
1082                );
1083                // One copy per instance: the oversampling jitter is sized for
1084                // the 3D scene and would smear pixel-placed matter.
1085                gl.uniform_1_u32(self.loc_n_copies.as_ref(), 1);
1086                gl.active_texture(glow::TEXTURE0);
1087                gl.bind_texture(glow::TEXTURE_2D, Some(self.atlas_tex));
1088                gl.bind_vertex_array(Some(self.vao));
1089                for loc in 2u32..=11 {
1090                    gl.vertex_attrib_divisor(loc, 1);
1091                }
1092                gl.draw_arrays_instanced(glow::TRIANGLES, 0, 6, count as i32);
1093                self.stats.draw_calls += 1;
1094            }
1095        }
1096
1097        // ── Passes 2+: bloom, composite, anti-aliasing ─────────────────────────
1098        let draws = self.postfx.run(
1099            gl, &self.render_config, fx, self.width, self.height, self.scene_time,
1100        );
1101        self.stats.draw_calls += draws;
1102    }
1103}
1104
1105// ── GL helper functions ────────────────────────────────────────────────────────
1106
1107/// Compile a vertex + fragment shader pair into a linked GL program.
1108unsafe fn compile_program(gl: &glow::Context, vert_src: &str, frag_src: &str) -> glow::Program {
1109    let vs = gl.create_shader(glow::VERTEX_SHADER).expect("create vertex shader");
1110    gl.shader_source(vs, vert_src);
1111    gl.compile_shader(vs);
1112    if !gl.get_shader_compile_status(vs) {
1113        let log = gl.get_shader_info_log(vs);
1114        panic!("Vertex shader compile error:\n{log}");
1115    }
1116
1117    let fs = gl.create_shader(glow::FRAGMENT_SHADER).expect("create fragment shader");
1118    gl.shader_source(fs, frag_src);
1119    gl.compile_shader(fs);
1120    if !gl.get_shader_compile_status(fs) {
1121        let log = gl.get_shader_info_log(fs);
1122        panic!("Fragment shader compile error:\n{log}");
1123    }
1124
1125    let prog = gl.create_program().expect("create shader program");
1126    gl.attach_shader(prog, vs);
1127    gl.attach_shader(prog, fs);
1128    gl.link_program(prog);
1129    if !gl.get_program_link_status(prog) {
1130        let log = gl.get_program_info_log(prog);
1131        panic!("Shader link error:\n{log}");
1132    }
1133
1134    gl.detach_shader(prog, vs);
1135    gl.detach_shader(prog, fs);
1136    gl.delete_shader(vs);
1137    gl.delete_shader(fs);
1138    prog
1139}
1140
1141/// Create VAO with per-vertex quad data (locations 0–1) and per-instance data (locations 2–10).
1142unsafe fn setup_vao(gl: &glow::Context) -> (glow::VertexArray, glow::Buffer, glow::Buffer) {
1143    let vao = gl.create_vertex_array().expect("create vao");
1144    gl.bind_vertex_array(Some(vao));
1145
1146    // ── Quad geometry VBO ─────────────────────────────────────────────────────
1147    let quad_vbo = gl.create_buffer().expect("create quad_vbo");
1148    gl.bind_buffer(glow::ARRAY_BUFFER, Some(quad_vbo));
1149    gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, cast_slice(&QUAD_VERTS), glow::STATIC_DRAW);
1150    // location 0: vec2 v_pos  (offset 0, stride 16)
1151    gl.vertex_attrib_pointer_f32(0, 2, glow::FLOAT, false, 16, 0);
1152    gl.enable_vertex_attrib_array(0);
1153    // location 1: vec2 v_uv   (offset 8, stride 16)
1154    gl.vertex_attrib_pointer_f32(1, 2, glow::FLOAT, false, 16, 8);
1155    gl.enable_vertex_attrib_array(1);
1156
1157    // ── Instance VBO (per-glyph data) ─────────────────────────────────────────
1158    let instance_vbo = gl.create_buffer().expect("create instance_vbo");
1159    gl.bind_buffer(glow::ARRAY_BUFFER, Some(instance_vbo));
1160
1161    let stride = std::mem::size_of::<GlyphInstance>() as i32;
1162
1163    // Macro: set up an instanced float attribute.
1164    macro_rules! inst_attr {
1165        ($loc:expr, $count:expr, $off:expr) => {{
1166            gl.vertex_attrib_pointer_f32($loc, $count, glow::FLOAT, false, stride, $off);
1167            gl.enable_vertex_attrib_array($loc);
1168            gl.vertex_attrib_divisor($loc, 1); // advance once per instance
1169        }};
1170    }
1171
1172    inst_attr!(2,  3,  0);  // i_position   vec3   @ byte 0
1173    inst_attr!(3,  2, 12);  // i_scale      vec2   @ byte 12
1174    inst_attr!(4,  1, 20);  // i_rotation   float  @ byte 20
1175    inst_attr!(5,  4, 24);  // i_color      vec4   @ byte 24
1176    inst_attr!(6,  1, 40);  // i_emission   float  @ byte 40
1177    inst_attr!(7,  3, 44);  // i_glow_color vec3   @ byte 44
1178    inst_attr!(8,  1, 56);  // i_glow_radius float @ byte 56
1179    inst_attr!(9,  2, 60);  // i_uv_offset  vec2   @ byte 60
1180    inst_attr!(10, 2, 68);  // i_uv_size    vec2   @ byte 68
1181    inst_attr!(11, 2, 76);  // i_flags      vec2   @ byte 76 (the former padding)
1182
1183    (vao, quad_vbo, instance_vbo)
1184}
1185
1186/// Upload a FontAtlas as an R8 GL texture and return the handle.
1187unsafe fn upload_atlas(gl: &glow::Context, atlas: &FontAtlas) -> glow::Texture {
1188    let tex = gl.create_texture().expect("create atlas texture");
1189    gl.bind_texture(glow::TEXTURE_2D, Some(tex));
1190    gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
1191    gl.tex_image_2d(
1192        glow::TEXTURE_2D, 0, glow::R8 as i32,
1193        atlas.width as i32, atlas.height as i32,
1194        0, glow::RED, glow::UNSIGNED_BYTE,
1195        glow::PixelUnpackData::Slice(Some(&atlas.pixels)),
1196    );
1197    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32);
1198    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32);
1199    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32);
1200    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32);
1201    tex
1202}
1203
1204// ── KeyCode → engine Key mapping ──────────────────────────────────────────────
1205
1206/// Map a winit `KeyCode` to the engine's `Key` enum. Returns `None` for unknown keys.
1207fn keycode_to_engine(kc: KeyCode) -> Option<Key> {
1208    Some(match kc {
1209        KeyCode::KeyA => Key::A, KeyCode::KeyB => Key::B, KeyCode::KeyC => Key::C,
1210        KeyCode::KeyD => Key::D, KeyCode::KeyE => Key::E, KeyCode::KeyF => Key::F,
1211        KeyCode::KeyG => Key::G, KeyCode::KeyH => Key::H, KeyCode::KeyI => Key::I,
1212        KeyCode::KeyJ => Key::J, KeyCode::KeyK => Key::K, KeyCode::KeyL => Key::L,
1213        KeyCode::KeyM => Key::M, KeyCode::KeyN => Key::N, KeyCode::KeyO => Key::O,
1214        KeyCode::KeyP => Key::P, KeyCode::KeyQ => Key::Q, KeyCode::KeyR => Key::R,
1215        KeyCode::KeyS => Key::S, KeyCode::KeyT => Key::T, KeyCode::KeyU => Key::U,
1216        KeyCode::KeyV => Key::V, KeyCode::KeyW => Key::W, KeyCode::KeyX => Key::X,
1217        KeyCode::KeyY => Key::Y, KeyCode::KeyZ => Key::Z,
1218        KeyCode::Digit1 => Key::Num1, KeyCode::Digit2 => Key::Num2,
1219        KeyCode::Digit3 => Key::Num3, KeyCode::Digit4 => Key::Num4,
1220        KeyCode::Digit5 => Key::Num5, KeyCode::Digit6 => Key::Num6,
1221        KeyCode::Digit7 => Key::Num7, KeyCode::Digit8 => Key::Num8,
1222        KeyCode::Digit9 => Key::Num9, KeyCode::Digit0 => Key::Num0,
1223        KeyCode::ArrowUp    => Key::Up,    KeyCode::ArrowDown  => Key::Down,
1224        KeyCode::ArrowLeft  => Key::Left,  KeyCode::ArrowRight => Key::Right,
1225        KeyCode::Enter | KeyCode::NumpadEnter => Key::Enter,
1226        KeyCode::Escape     => Key::Escape,
1227        KeyCode::Space      => Key::Space,
1228        KeyCode::Backspace  => Key::Backspace,
1229        KeyCode::Tab        => Key::Tab,
1230        KeyCode::ShiftLeft   => Key::LShift,  KeyCode::ShiftRight   => Key::RShift,
1231        KeyCode::ControlLeft => Key::LCtrl,   KeyCode::ControlRight => Key::RCtrl,
1232        KeyCode::AltLeft     => Key::LAlt,    KeyCode::AltRight     => Key::RAlt,
1233        KeyCode::F1  => Key::F1,  KeyCode::F2  => Key::F2,  KeyCode::F3  => Key::F3,
1234        KeyCode::F4  => Key::F4,  KeyCode::F5  => Key::F5,  KeyCode::F6  => Key::F6,
1235        KeyCode::F7  => Key::F7,  KeyCode::F8  => Key::F8,  KeyCode::F9  => Key::F9,
1236        KeyCode::F10 => Key::F10, KeyCode::F11 => Key::F11, KeyCode::F12 => Key::F12,
1237        KeyCode::Slash        => Key::Slash,
1238        KeyCode::Backslash    => Key::Backslash,
1239        KeyCode::Period       => Key::Period,
1240        KeyCode::Comma        => Key::Comma,
1241        KeyCode::Semicolon    => Key::Semicolon,
1242        KeyCode::Quote        => Key::Quote,
1243        KeyCode::BracketLeft  => Key::LBracket,
1244        KeyCode::BracketRight => Key::RBracket,
1245        KeyCode::Minus        => Key::Minus,
1246        KeyCode::Equal        => Key::Equals,
1247        KeyCode::Backquote    => Key::Backtick,
1248        KeyCode::PageUp       => Key::PageUp,
1249        KeyCode::PageDown     => Key::PageDown,
1250        KeyCode::Home         => Key::Home,
1251        KeyCode::End          => Key::End,
1252        KeyCode::Insert       => Key::Insert,
1253        KeyCode::Delete       => Key::Delete,
1254        _ => return None,
1255    })
1256}