Skip to main content

proof_engine/glyph/
sdf_batch.rs

1//! SDF-aware instanced glyph batch rendering.
2//!
3//! Adapts the existing `GlyphInstance` / `GlyphBatcher` approach for SDF rendering:
4//!   - Same VBO/instance layout, but rendered with `sdf_glyph.frag`
5//!   - Automatic smoothing calculation based on glyph screen-space size
6//!   - Scale-dependent threshold for consistent edge quality at all sizes
7//!   - Per-glyph SDF effects (outline, shadow, glow, bold, wave, glitch)
8
9use glam::{Vec2, Vec3, Vec4, Mat4};
10use std::collections::HashMap;
11
12use super::batch::{GlyphInstance, GlyphBatch, BatchKey, BlendMode, RenderLayerOrd};
13use super::sdf_atlas::SdfAtlas;
14use super::sdf_generator::SdfGlyphMetric;
15use super::RenderLayer;
16
17// ── SDF Instance (extended) ─────────────────────────────────────────────────
18
19/// Per-instance SDF-specific data that supplements the base GlyphInstance.
20///
21/// This is kept CPU-side and used to set shader uniforms per-batch or to
22/// pack into an extended instance buffer for per-glyph effects.
23#[repr(C)]
24#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
25pub struct SdfInstanceExtra {
26    /// SDF threshold: 0.5 = normal, 0.45 = bold, etc.
27    pub threshold: f32,
28    /// Smoothing factor (computed from screen-space size).
29    pub smoothing: f32,
30    /// Outline parameters: [enabled, width, 0, 0].
31    pub outline_params: [f32; 4],
32    /// Outline color RGBA.
33    pub outline_color: [f32; 4],
34    /// Shadow parameters: [enabled, softness, uv_offset_x, uv_offset_y].
35    pub shadow_params: [f32; 4],
36    /// Shadow color RGBA.
37    pub shadow_color: [f32; 4],
38    /// Glow parameters: [enabled, radius, 0, 0].
39    pub glow_params: [f32; 4],
40    /// Glow color RGBA.
41    pub glow_color: [f32; 4],
42    /// UV distortion: [wave_amp, wave_freq, shake, glitch].
43    pub distortion: [f32; 4],
44}
45
46impl Default for SdfInstanceExtra {
47    fn default() -> Self {
48        Self {
49            threshold: 0.5,
50            smoothing: 0.05,
51            outline_params: [0.0; 4],
52            outline_color: [0.0, 0.0, 0.0, 1.0],
53            shadow_params: [0.0; 4],
54            shadow_color: [0.0, 0.0, 0.0, 0.6],
55            glow_params: [0.0; 4],
56            glow_color: [1.0, 1.0, 1.0, 0.5],
57            distortion: [0.0; 4],
58        }
59    }
60}
61
62// ── SDF Batch ───────────────────────────────────────────────────────────────
63
64/// A batch of SDF glyph instances ready for rendering.
65pub struct SdfGlyphBatch {
66    pub key: BatchKey,
67    pub base_instances: Vec<GlyphInstance>,
68    pub sdf_extras: Vec<SdfInstanceExtra>,
69}
70
71impl SdfGlyphBatch {
72    pub fn new(key: BatchKey) -> Self {
73        Self {
74            key,
75            base_instances: Vec::with_capacity(64),
76            sdf_extras: Vec::with_capacity(64),
77        }
78    }
79
80    pub fn clear(&mut self) {
81        self.base_instances.clear();
82        self.sdf_extras.clear();
83    }
84
85    pub fn push(&mut self, base: GlyphInstance, extra: SdfInstanceExtra) {
86        self.base_instances.push(base);
87        self.sdf_extras.push(extra);
88    }
89
90    pub fn len(&self) -> usize {
91        self.base_instances.len()
92    }
93
94    pub fn is_empty(&self) -> bool {
95        self.base_instances.is_empty()
96    }
97
98    /// Raw byte slice of base instances for GPU upload.
99    pub fn base_bytes(&self) -> &[u8] {
100        bytemuck::cast_slice(&self.base_instances)
101    }
102
103    /// Raw byte slice of SDF extras for GPU upload.
104    pub fn extra_bytes(&self) -> &[u8] {
105        bytemuck::cast_slice(&self.sdf_extras)
106    }
107
108    /// Sort instances back-to-front by Z.
109    pub fn sort_back_to_front(&mut self) {
110        // Create index array, sort by Z, then reorder both arrays.
111        let mut indices: Vec<usize> = (0..self.base_instances.len()).collect();
112        indices.sort_by(|&a, &b| {
113            self.base_instances[b].position[2]
114                .partial_cmp(&self.base_instances[a].position[2])
115                .unwrap_or(std::cmp::Ordering::Equal)
116        });
117
118        let old_base = self.base_instances.clone();
119        let old_extra = self.sdf_extras.clone();
120        for (new_idx, &old_idx) in indices.iter().enumerate() {
121            self.base_instances[new_idx] = old_base[old_idx];
122            self.sdf_extras[new_idx] = old_extra[old_idx];
123        }
124    }
125}
126
127// ── SDF Batcher ─────────────────────────────────────────────────────────────
128
129/// Pending SDF glyph submission.
130pub struct PendingSdfGlyph {
131    pub key: BatchKey,
132    pub base: GlyphInstance,
133    pub extra: SdfInstanceExtra,
134    pub depth: f32,
135}
136
137/// Builds and sorts SDF glyph batches each frame.
138pub struct SdfGlyphBatcher {
139    pending: Vec<PendingSdfGlyph>,
140    batches: Vec<SdfGlyphBatch>,
141    stats: SdfBatchStats,
142}
143
144#[derive(Default, Debug, Clone)]
145pub struct SdfBatchStats {
146    pub total_glyphs: usize,
147    pub batch_count: usize,
148    pub outlined_glyphs: usize,
149    pub shadowed_glyphs: usize,
150}
151
152impl SdfGlyphBatcher {
153    pub fn new() -> Self {
154        Self {
155            pending: Vec::with_capacity(4096),
156            batches: Vec::with_capacity(16),
157            stats: SdfBatchStats::default(),
158        }
159    }
160
161    pub fn begin(&mut self) {
162        self.pending.clear();
163        self.stats = SdfBatchStats::default();
164    }
165
166    pub fn push(&mut self, key: BatchKey, base: GlyphInstance, extra: SdfInstanceExtra, depth: f32) {
167        self.pending.push(PendingSdfGlyph { key, base, extra, depth });
168    }
169
170    pub fn push_simple(
171        &mut self,
172        layer: RenderLayer,
173        base: GlyphInstance,
174        smoothing: f32,
175        depth: f32,
176    ) {
177        let key = BatchKey::default_for_layer(layer);
178        let extra = SdfInstanceExtra {
179            smoothing,
180            ..SdfInstanceExtra::default()
181        };
182        self.push(key, base, extra, depth);
183    }
184
185    pub fn finish(&mut self) {
186        self.stats.total_glyphs = self.pending.len();
187
188        // Sort by batch key then depth.
189        self.pending.sort_by(|a, b| {
190            a.key
191                .cmp(&b.key)
192                .then(b.depth.partial_cmp(&a.depth).unwrap_or(std::cmp::Ordering::Equal))
193        });
194
195        self.batches.clear();
196        let mut current_key: Option<BatchKey> = None;
197
198        for item in &self.pending {
199            if item.extra.outline_params[0] > 0.0 {
200                self.stats.outlined_glyphs += 1;
201            }
202            if item.extra.shadow_params[0] > 0.0 {
203                self.stats.shadowed_glyphs += 1;
204            }
205
206            if current_key != Some(item.key) {
207                self.batches.push(SdfGlyphBatch::new(item.key));
208                current_key = Some(item.key);
209            }
210
211            let batch = self.batches.last_mut().unwrap();
212            batch.base_instances.push(item.base);
213            batch.sdf_extras.push(item.extra);
214        }
215
216        self.stats.batch_count = self.batches.len();
217    }
218
219    pub fn batches(&self) -> &[SdfGlyphBatch] {
220        &self.batches
221    }
222
223    pub fn stats(&self) -> &SdfBatchStats {
224        &self.stats
225    }
226
227    pub fn instance_count(&self) -> usize {
228        self.batches.iter().map(|b| b.len()).sum()
229    }
230}
231
232impl Default for SdfGlyphBatcher {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238// ── SDF Text Layout Helper ──────────────────────────────────────────────────
239
240/// Lay out a string of text using the SDF atlas metrics and return GlyphInstance
241/// + SdfInstanceExtra pairs ready for batching.
242///
243/// `position` is the top-left of the text block in world space.
244/// `scale` is the desired font size in world units.
245pub fn layout_sdf_text(
246    atlas: &SdfAtlas,
247    text: &str,
248    position: Vec3,
249    scale: f32,
250    color: Vec4,
251    emission: f32,
252    screen_px_per_unit: f32,
253) -> Vec<(GlyphInstance, SdfInstanceExtra)> {
254    let scale_factor = scale / atlas.font_size_px;
255    let smoothing = atlas.compute_smoothing(screen_px_per_unit, scale);
256    let mut result = Vec::with_capacity(text.len());
257    let mut cursor_x = position.x;
258
259    for ch in text.chars() {
260        if ch == ' ' {
261            let metric = atlas.metric_for(ch);
262            cursor_x += metric.advance * scale_factor;
263            continue;
264        }
265
266        let metric = atlas.metric_for(ch);
267        let uv = metric.uv_rect;
268
269        let glyph_pos = Vec3::new(
270            cursor_x + metric.bearing.x * scale_factor,
271            position.y - metric.bearing.y * scale_factor,
272            position.z,
273        );
274
275        let glyph_scale = Vec2::new(
276            metric.size.x * scale_factor,
277            metric.size.y * scale_factor,
278        );
279
280        let base = GlyphInstance {
281            position: glyph_pos.to_array(),
282            scale: [glyph_scale.x, glyph_scale.y],
283            rotation: 0.0,
284            color: color.to_array(),
285            emission,
286            glow_color: [color.x, color.y, color.z],
287            glow_radius: 0.0,
288            uv_offset: [uv[0], uv[1]],
289            uv_size: [uv[2] - uv[0], uv[3] - uv[1]],
290            _pad: [0.0; 2],
291        };
292
293        let extra = SdfInstanceExtra {
294            threshold: 0.5,
295            smoothing,
296            ..SdfInstanceExtra::default()
297        };
298
299        result.push((base, extra));
300        cursor_x += metric.advance * scale_factor;
301    }
302
303    result
304}
305
306/// Lay out text with SDF effects applied.
307pub fn layout_sdf_text_with_effects(
308    atlas: &SdfAtlas,
309    text: &str,
310    position: Vec3,
311    scale: f32,
312    color: Vec4,
313    emission: f32,
314    screen_px_per_unit: f32,
315    effects: &super::sdf_atlas::SdfEffects,
316) -> Vec<(GlyphInstance, SdfInstanceExtra)> {
317    let mut result = layout_sdf_text(atlas, text, position, scale, color, emission, screen_px_per_unit);
318
319    for (_, extra) in &mut result {
320        if effects.bold {
321            extra.threshold = 0.45;
322        }
323        if effects.outline {
324            extra.outline_params = [1.0, effects.outline_width, 0.0, 0.0];
325            extra.outline_color = effects.outline_color.to_array();
326        }
327        if effects.shadow {
328            let uv_off = atlas.shadow_uv_offset(effects.shadow_offset, scale);
329            extra.shadow_params = [1.0, effects.shadow_softness, uv_off.x, uv_off.y];
330            extra.shadow_color = effects.shadow_color.to_array();
331        }
332        if effects.glow {
333            extra.glow_params = [1.0, effects.glow_radius, 0.0, 0.0];
334            extra.glow_color = effects.glow_color.to_array();
335        }
336        extra.distortion = [
337            effects.wave_amplitude,
338            effects.wave_frequency,
339            effects.shake_amount,
340            effects.glitch_intensity,
341        ];
342    }
343
344    result
345}
346
347// ── Uniform block for batch-level SDF params ────────────────────────────────
348
349/// Uniforms that are set once per SDF draw call (not per-instance).
350#[derive(Clone, Debug)]
351pub struct SdfBatchUniforms {
352    /// View-projection matrix.
353    pub view_proj: Mat4,
354    /// Default threshold (can be overridden per-instance).
355    pub threshold: f32,
356    /// Default smoothing (can be overridden per-instance).
357    pub smoothing: f32,
358    /// Current time for animated effects.
359    pub time: f32,
360    /// Screen dimensions for pixel-space calculations.
361    pub screen_size: Vec2,
362}
363
364impl Default for SdfBatchUniforms {
365    fn default() -> Self {
366        Self {
367            view_proj: Mat4::IDENTITY,
368            threshold: 0.5,
369            smoothing: 0.05,
370            time: 0.0,
371            screen_size: Vec2::new(1280.0, 800.0),
372        }
373    }
374}
375
376// ── Tests ───────────────────────────────────────────────────────────────────
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn make_sdf_instance(z: f32) -> (GlyphInstance, SdfInstanceExtra) {
383        let base = GlyphInstance::simple(Vec3::new(0.0, 0.0, z), Vec2::ZERO, Vec2::new(0.1, 0.1));
384        let extra = SdfInstanceExtra::default();
385        (base, extra)
386    }
387
388    #[test]
389    fn sdf_batcher_groups_by_key() {
390        let mut batcher = SdfGlyphBatcher::new();
391        batcher.begin();
392
393        let world_key = BatchKey::new(RenderLayer::World, BlendMode::Alpha, 0);
394        let ui_key = BatchKey::new(RenderLayer::UI, BlendMode::Alpha, 0);
395
396        let (b1, e1) = make_sdf_instance(0.0);
397        let (b2, e2) = make_sdf_instance(1.0);
398        let (b3, e3) = make_sdf_instance(0.0);
399
400        batcher.push(world_key, b1, e1, 0.0);
401        batcher.push(world_key, b2, e2, 1.0);
402        batcher.push(ui_key, b3, e3, 0.0);
403
404        batcher.finish();
405
406        assert_eq!(batcher.batches().len(), 2);
407        assert_eq!(batcher.instance_count(), 3);
408    }
409
410    #[test]
411    fn sdf_batch_sort_back_to_front() {
412        let key = BatchKey::new(RenderLayer::World, BlendMode::Alpha, 0);
413        let mut batch = SdfGlyphBatch::new(key);
414        let (b1, e1) = make_sdf_instance(0.0);
415        let (b2, e2) = make_sdf_instance(5.0);
416        let (b3, e3) = make_sdf_instance(2.0);
417        batch.push(b1, e1);
418        batch.push(b2, e2);
419        batch.push(b3, e3);
420        batch.sort_back_to_front();
421
422        let zs: Vec<f32> = batch.base_instances.iter().map(|i| i.position[2]).collect();
423        assert!(zs[0] >= zs[1] && zs[1] >= zs[2]);
424    }
425
426    #[test]
427    fn sdf_instance_extra_size() {
428        // Verify the struct is Pod-safe and has expected layout.
429        let extra = SdfInstanceExtra::default();
430        let bytes: &[u8] = bytemuck::bytes_of(&extra);
431        assert_eq!(bytes.len(), std::mem::size_of::<SdfInstanceExtra>());
432    }
433}