Skip to main content

proof_engine/glyph/
batch.rs

1//! Batched glyph rendering — layer-sorted, blend-mode-separated instanced draw calls.
2//!
3//! The renderer needs to:
4//!  1. Sort all active glyphs by (RenderLayer, blend_mode) to minimize state changes
5//!  2. Pack each batch into a `GlyphInstance` array for the GPU
6//!  3. Issue one `draw_arrays_instanced` call per batch
7//!
8//! # Batch key
9//!
10//! `(RenderLayer, BlendMode)` — changes in either require a new draw call.
11//! Within each batch, instances are further sorted by Z (back-to-front) for
12//! transparent geometry.
13
14use glam::{Vec2, Vec3, Vec4};
15use std::collections::HashMap;
16use crate::glyph::RenderLayer;
17
18// ── GlyphInstance ─────────────────────────────────────────────────────────────
19
20/// Per-instance GPU data. Must match the vertex shader layout exactly.
21///
22/// Layout (84 bytes total, 21 × f32):
23///   position   : vec3  (offset  0)
24///   scale      : vec2  (offset 12)
25///   rotation   : f32   (offset 20)
26///   color      : vec4  (offset 24)
27///   emission   : f32   (offset 40)
28///   glow_color : vec3  (offset 44)
29///   glow_radius: f32   (offset 56)
30///   uv_offset  : vec2  (offset 60)
31///   uv_size    : vec2  (offset 68)
32///   _pad       : vec2  (offset 76)  — aligns to 84 bytes
33#[repr(C)]
34#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
35pub struct GlyphInstance {
36    pub position:    [f32; 3],
37    pub scale:       [f32; 2],
38    pub rotation:    f32,
39    pub color:       [f32; 4],
40    pub emission:    f32,
41    pub glow_color:  [f32; 3],
42    pub glow_radius: f32,
43    pub uv_offset:   [f32; 2],
44    pub uv_size:     [f32; 2],
45    pub _pad:        [f32; 2],
46}
47
48// Note: GlyphInstance must be exactly 84 bytes — verified in tests below.
49
50// ── Blend mode ────────────────────────────────────────────────────────────────
51
52/// GPU blend mode for a batch.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
54pub enum BlendMode {
55    /// Standard alpha blending: src × src_alpha + dst × (1 - src_alpha).
56    Alpha,
57    /// Additive: src × src_alpha + dst × 1.  Great for glows and bloom.
58    Additive,
59    /// Multiplicative: dst × src. Darkens/tints.
60    Multiply,
61    /// Screen: 1 - (1 - src)(1 - dst). Brightens.
62    Screen,
63}
64
65// ── Batch key ─────────────────────────────────────────────────────────────────
66
67/// Determines which draw call a glyph belongs to.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub struct BatchKey {
70    pub layer: RenderLayerOrd,
71    pub blend: BlendMode,
72    /// Texture atlas page (0 for single-atlas setups).
73    pub atlas_page: u8,
74}
75
76/// Ordered wrapper for RenderLayer (to allow sorting by layer).
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
78pub struct RenderLayerOrd(pub u8);
79
80impl RenderLayerOrd {
81    pub fn from_layer(layer: RenderLayer) -> Self {
82        Self(match layer {
83            RenderLayer::Background => 0,
84            RenderLayer::World      => 1,
85            RenderLayer::Entity     => 2,
86            RenderLayer::Particle   => 3,
87            RenderLayer::Overlay     => 4,
88            RenderLayer::UI         => 5,
89        })
90    }
91}
92
93impl BatchKey {
94    pub fn new(layer: RenderLayer, blend: BlendMode, atlas_page: u8) -> Self {
95        Self { layer: RenderLayerOrd::from_layer(layer), blend, atlas_page }
96    }
97
98    pub fn default_for_layer(layer: RenderLayer) -> Self {
99        let blend = match layer {
100            RenderLayer::Overlay | RenderLayer::Particle => BlendMode::Additive,
101            _ => BlendMode::Alpha,
102        };
103        Self::new(layer, blend, 0)
104    }
105}
106
107impl PartialOrd for BatchKey {
108    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
109        Some(self.cmp(other))
110    }
111}
112
113impl Ord for BatchKey {
114    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
115        self.layer.cmp(&other.layer)
116            .then(self.blend.cmp(&other.blend))
117            .then(self.atlas_page.cmp(&other.atlas_page))
118    }
119}
120
121// ── CPU-side batch ────────────────────────────────────────────────────────────
122
123/// A CPU-side instance buffer for one draw call.
124#[derive(Debug)]
125pub struct GlyphBatch {
126    pub key:       BatchKey,
127    pub instances: Vec<GlyphInstance>,
128}
129
130impl GlyphBatch {
131    pub fn new(key: BatchKey) -> Self {
132        Self { key, instances: Vec::with_capacity(64) }
133    }
134
135    pub fn clear(&mut self) { self.instances.clear(); }
136
137    pub fn push(&mut self, inst: GlyphInstance) { self.instances.push(inst); }
138
139    pub fn len(&self)      -> usize { self.instances.len() }
140    pub fn is_empty(&self) -> bool  { self.instances.is_empty() }
141
142    /// Raw byte slice for GPU upload.
143    pub fn as_bytes(&self) -> &[u8] {
144        bytemuck::cast_slice(&self.instances)
145    }
146
147    /// Sort instances back-to-front by Z position (for alpha blending correctness).
148    pub fn sort_back_to_front(&mut self) {
149        self.instances.sort_by(|a, b| {
150            b.position[2].partial_cmp(&a.position[2]).unwrap_or(std::cmp::Ordering::Equal)
151        });
152    }
153
154    /// Sort instances front-to-back by Z (for depth occlusion passes).
155    pub fn sort_front_to_back(&mut self) {
156        self.instances.sort_by(|a, b| {
157            a.position[2].partial_cmp(&b.position[2]).unwrap_or(std::cmp::Ordering::Equal)
158        });
159    }
160}
161
162// ── Batch sorter / builder ────────────────────────────────────────────────────
163
164/// Pending item — a glyph to be assigned to a batch.
165pub struct PendingGlyph {
166    pub key:      BatchKey,
167    pub instance: GlyphInstance,
168    pub depth:    f32,  // for Z sorting within a layer
169}
170
171/// Builds and sorts batches from a flat list of glyphs each frame.
172///
173/// Usage:
174/// ```text
175/// batcher.begin();
176/// for glyph in glyphs { batcher.push(glyph, key, instance); }
177/// batcher.finish();
178/// for batch in batcher.batches() { gpu.draw(batch); }
179/// ```
180pub struct GlyphBatcher {
181    pending:  Vec<PendingGlyph>,
182    batches:  Vec<GlyphBatch>,
183    stats:    BatchStats,
184}
185
186/// Statistics from the last `finish()` call.
187#[derive(Default, Debug, Clone)]
188pub struct BatchStats {
189    pub total_glyphs:  usize,
190    pub batch_count:   usize,
191    pub alpha_glyphs:  usize,
192    pub additive_glyphs: usize,
193}
194
195impl GlyphBatcher {
196    pub fn new() -> Self {
197        Self {
198            pending: Vec::with_capacity(4096),
199            batches: Vec::with_capacity(16),
200            stats:   BatchStats::default(),
201        }
202    }
203
204    /// Clear pending list and stats. Call at the start of each frame.
205    pub fn begin(&mut self) {
206        self.pending.clear();
207        self.stats = BatchStats::default();
208    }
209
210    /// Submit a glyph for batching.
211    pub fn push(&mut self, key: BatchKey, instance: GlyphInstance, depth: f32) {
212        self.pending.push(PendingGlyph { key, instance, depth });
213    }
214
215    /// Submit a glyph using default batch key for its layer.
216    pub fn push_default(&mut self, layer: RenderLayer, instance: GlyphInstance, depth: f32) {
217        let key = BatchKey::default_for_layer(layer);
218        self.push(key, instance, depth);
219    }
220
221    /// Sort and group all pending glyphs into batches. Call after all pushes.
222    pub fn finish(&mut self) {
223        self.stats.total_glyphs = self.pending.len();
224
225        // Sort pending by batch key (layer, blend, atlas) then depth
226        self.pending.sort_by(|a, b| {
227            a.key.cmp(&b.key)
228                .then(b.depth.partial_cmp(&a.depth).unwrap_or(std::cmp::Ordering::Equal))
229        });
230
231        // Group into batches
232        self.batches.clear();
233        let mut current_key: Option<BatchKey> = None;
234
235        for item in &self.pending {
236            match &item.blend_type() {
237                BlendMode::Alpha | BlendMode::Multiply | BlendMode::Screen => {
238                    self.stats.alpha_glyphs += 1;
239                }
240                BlendMode::Additive => {
241                    self.stats.additive_glyphs += 1;
242                }
243            }
244
245            if current_key != Some(item.key) {
246                self.batches.push(GlyphBatch::new(item.key));
247                current_key = Some(item.key);
248            }
249
250            self.batches.last_mut().unwrap().instances.push(item.instance);
251        }
252
253        self.stats.batch_count = self.batches.len();
254    }
255
256    /// Iterate over completed batches in draw order.
257    pub fn batches(&self) -> &[GlyphBatch] {
258        &self.batches
259    }
260
261    /// Statistics from the last `finish()` call.
262    pub fn stats(&self) -> &BatchStats { &self.stats }
263
264    /// Total instance count across all batches.
265    pub fn instance_count(&self) -> usize {
266        self.batches.iter().map(|b| b.len()).sum()
267    }
268}
269
270impl PendingGlyph {
271    fn blend_type(&self) -> BlendMode { self.key.blend }
272}
273
274impl Default for GlyphBatcher {
275    fn default() -> Self { Self::new() }
276}
277
278// ── Convenience constructors for GlyphInstance ────────────────────────────────
279
280impl GlyphInstance {
281    /// Build a GlyphInstance from common parameters.
282    pub fn build(
283        position:    Vec3,
284        scale:       Vec2,
285        rotation:    f32,
286        color:       Vec4,
287        emission:    f32,
288        glow_color:  Vec3,
289        glow_radius: f32,
290        uv_offset:   Vec2,
291        uv_size:     Vec2,
292    ) -> Self {
293        Self {
294            position:    position.into(),
295            scale:       scale.into(),
296            rotation,
297            color:       color.into(),
298            emission,
299            glow_color:  glow_color.into(),
300            glow_radius,
301            uv_offset:   uv_offset.into(),
302            uv_size:     uv_size.into(),
303            _pad:        [0.0; 2],
304        }
305    }
306
307    /// A simple opaque white glyph at a position (useful for testing).
308    pub fn simple(position: Vec3, uv_offset: Vec2, uv_size: Vec2) -> Self {
309        Self::build(
310            position,
311            Vec2::ONE,
312            0.0,
313            Vec4::ONE,
314            0.0,
315            Vec3::ZERO,
316            0.0,
317            uv_offset,
318            uv_size,
319        )
320    }
321
322    /// A glowing glyph.
323    pub fn glowing(position: Vec3, color: Vec4, emission: f32, glow_radius: f32,
324                   uv_offset: Vec2, uv_size: Vec2) -> Self {
325        Self::build(
326            position,
327            Vec2::ONE,
328            0.0,
329            color,
330            emission,
331            Vec3::new(color.x, color.y, color.z),
332            glow_radius,
333            uv_offset,
334            uv_size,
335        )
336    }
337}
338
339// ── Multi-frame atlas upload tracking ─────────────────────────────────────────
340
341/// Tracks which atlas pages have been uploaded this frame (avoids redundant uploads).
342#[derive(Default)]
343pub struct AtlasUploadTracker {
344    dirty_pages: std::collections::HashSet<u8>,
345}
346
347impl AtlasUploadTracker {
348    pub fn mark_dirty(&mut self, page: u8) { self.dirty_pages.insert(page); }
349    pub fn is_dirty(&self, page: u8) -> bool { self.dirty_pages.contains(&page) }
350    pub fn clear(&mut self) { self.dirty_pages.clear(); }
351    pub fn dirty_pages(&self) -> impl Iterator<Item = u8> + '_ {
352        self.dirty_pages.iter().copied()
353    }
354}
355
356// ── Tests ─────────────────────────────────────────────────────────────────────
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn make_instance(z: f32) -> GlyphInstance {
363        GlyphInstance::simple(Vec3::new(0.0, 0.0, z), Vec2::ZERO, Vec2::new(0.1, 0.1))
364    }
365
366    #[test]
367    fn batcher_groups_by_key() {
368        let mut batcher = GlyphBatcher::new();
369        batcher.begin();
370
371        let world_key = BatchKey::new(RenderLayer::World, BlendMode::Alpha, 0);
372        let ui_key    = BatchKey::new(RenderLayer::UI,    BlendMode::Alpha, 0);
373
374        batcher.push(world_key, make_instance(0.0), 0.0);
375        batcher.push(world_key, make_instance(1.0), 1.0);
376        batcher.push(ui_key,    make_instance(0.0), 0.0);
377
378        batcher.finish();
379
380        assert_eq!(batcher.batches().len(), 2, "Expected 2 batches");
381        assert_eq!(batcher.instance_count(), 3);
382    }
383
384    #[test]
385    fn batches_sorted_by_layer() {
386        let mut batcher = GlyphBatcher::new();
387        batcher.begin();
388
389        batcher.push_default(RenderLayer::UI,    make_instance(0.0), 0.0);
390        batcher.push_default(RenderLayer::World, make_instance(0.0), 0.0);
391
392        batcher.finish();
393
394        let batches = batcher.batches();
395        // World (layer=1) should come before UI (layer=5)
396        assert!(batches[0].key.layer < batches[1].key.layer);
397    }
398
399    #[test]
400    fn stats_correct() {
401        let mut batcher = GlyphBatcher::new();
402        batcher.begin();
403        batcher.push_default(RenderLayer::World,    make_instance(0.0), 0.0);
404        batcher.push_default(RenderLayer::World,    make_instance(1.0), 1.0);
405        batcher.push_default(RenderLayer::Particle, make_instance(0.0), 0.0);
406        batcher.finish();
407
408        assert_eq!(batcher.stats().total_glyphs, 3);
409    }
410
411    #[test]
412    fn glyph_instance_size_is_84() {
413        assert_eq!(std::mem::size_of::<GlyphInstance>(), 84);
414    }
415
416    #[test]
417    fn sort_back_to_front_orders_by_descending_z() {
418        let key = BatchKey::new(RenderLayer::World, BlendMode::Alpha, 0);
419        let mut batch = GlyphBatch::new(key);
420        batch.push(make_instance(0.0));
421        batch.push(make_instance(5.0));
422        batch.push(make_instance(2.0));
423        batch.sort_back_to_front();
424
425        let zs: Vec<f32> = batch.instances.iter().map(|i| i.position[2]).collect();
426        assert!(zs[0] >= zs[1] && zs[1] >= zs[2]);
427    }
428}