hoplite 0.1.9

A creative coding framework for Rust that gets out of your way
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
//! Asset management for the Hoplite engine.
//!
//! This module provides resource loading and caching functionality, with a focus on
//! font rendering through GPU-accelerated font atlases. The primary types are:
//!
//! - [`Assets`] - The central asset manager for loading and caching resources
//! - [`FontAtlas`] - A pre-rasterized font texture for efficient text rendering
//! - [`FontId`] - An opaque handle to a loaded font
//! - [`GlyphInfo`] - Metrics and UV coordinates for individual glyphs
//!
//! # Font Rendering Pipeline
//!
//! Fonts are loaded using the `fontdue` library, rasterized at a specified size, and
//! packed into a texture atlas. The atlas uses a simple row-packing algorithm that
//! automatically grows to accommodate all ASCII printable characters (32-126).
//!
//! # Example
//!
//! ```ignore
//! // Load a custom font
//! let font_id = assets.load_font(&gpu, "fonts/MyFont.ttf", 24.0);
//!
//! // Or use the embedded default font
//! let default_id = assets.default_font(&gpu, 16.0);
//!
//! // Access the font atlas for rendering
//! if let Some(atlas) = assets.font(font_id) {
//!     let width = atlas.measure("Hello, world!");
//! }
//! ```

use crate::gpu::GpuContext;
use fontdue::{Font, FontSettings};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;

/// Opaque identifier for a loaded font.
///
/// This handle is returned when loading fonts through [`Assets`] and can be used
/// to retrieve the corresponding [`FontAtlas`] for rendering. The handle is lightweight
/// (just an index) and can be freely copied and stored.
///
/// # Example
///
/// ```ignore
/// let font_id = assets.load_font(&gpu, "my_font.ttf", 24.0);
/// let atlas = assets.font(font_id).expect("Font should exist");
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FontId(pub(crate) usize);

/// Information about a single glyph in the font atlas.
///
/// Contains all the data needed to render a character from the atlas texture:
/// - UV coordinates for sampling the correct region of the atlas
/// - Pixel dimensions for quad sizing
/// - Positioning offsets for proper baseline alignment
/// - Advance width for cursor movement
///
/// These metrics come directly from the `fontdue` rasterizer and follow standard
/// font metric conventions where positive Y points upward from the baseline.
#[derive(Clone, Copy, Debug)]
pub struct GlyphInfo {
    /// UV coordinates in the atlas as `[x, y, width, height]`, normalized to `[0, 1]`.
    ///
    /// Use these to calculate texture sampling coordinates:
    /// - Top-left: `(uv[0], uv[1])`
    /// - Bottom-right: `(uv[0] + uv[2], uv[1] + uv[3])`
    pub uv: [f32; 4],
    /// Width of the glyph in pixels.
    pub width: u32,
    /// Height of the glyph in pixels.
    pub height: u32,
    /// Horizontal offset from the cursor position to where the glyph should be drawn.
    ///
    /// This is the bearing/origin offset that positions the glyph correctly relative
    /// to the text cursor. Can be negative for glyphs that extend left of the cursor.
    pub offset_x: f32,
    /// Vertical offset from the baseline to the top of the glyph.
    ///
    /// Combined with `height`, this determines vertical positioning relative to the
    /// text baseline.
    pub offset_y: f32,
    /// How far to advance the cursor horizontally after rendering this glyph.
    ///
    /// This includes the glyph width plus any inter-character spacing defined by
    /// the font.
    pub advance: f32,
}

/// A font atlas containing pre-rasterized glyphs.
///
/// Font atlases pack multiple glyph bitmaps into a single GPU texture for efficient
/// text rendering. Instead of rendering each character individually, text renderers
/// can draw quads that sample different regions of the atlas texture.
///
/// # Atlas Generation
///
/// When created, the atlas:
/// 1. Rasterizes all ASCII printable characters (32-126) at the specified size
/// 2. Packs glyphs into a texture using row-based bin packing
/// 3. Starts at 512x512 and doubles dimensions as needed to fit all glyphs
/// 4. Stores glyph metrics for text layout calculations
///
/// # Texture Format
///
/// The atlas uses `R8Unorm` format (single-channel grayscale) to minimize memory.
/// Shaders should sample the red channel and use it as alpha for anti-aliased rendering.
///
/// # Thread Safety
///
/// The atlas is not `Send` or `Sync` due to the wgpu texture handles. Access should
/// be confined to the render thread.
pub struct FontAtlas {
    /// The GPU texture containing packed glyph bitmaps.
    pub texture: wgpu::Texture,
    /// Texture view for binding to shaders.
    pub view: wgpu::TextureView,
    /// Linear-filtered sampler for smooth text rendering.
    pub sampler: wgpu::Sampler,
    /// Mapping from characters to their glyph information.
    glyphs: HashMap<char, GlyphInfo>,
    /// The original font, retained for potential dynamic glyph rasterization.
    #[allow(dead_code)]
    font: Font,
    /// Font size in pixels that this atlas was rasterized at.
    size: f32,
    /// Recommended line height for this font and size.
    line_height: f32,
}

impl FontAtlas {
    /// Creates a new font atlas from TTF/OTF font data.
    ///
    /// This rasterizes all ASCII printable characters (codes 32-126) at the specified
    /// pixel size and packs them into a GPU texture atlas.
    ///
    /// # Arguments
    ///
    /// * `gpu` - The GPU context for creating textures
    /// * `font_data` - Raw bytes of a TTF or OTF font file
    /// * `size` - Font size in pixels (e.g., 16.0, 24.0)
    ///
    /// # Panics
    ///
    /// Panics if the font data cannot be parsed by `fontdue`.
    ///
    /// # Performance
    ///
    /// Atlas creation involves CPU-side rasterization of ~95 glyphs and a texture
    /// upload. This should be done during loading, not per-frame.
    pub fn new(gpu: &GpuContext, font_data: &[u8], size: f32) -> Self {
        let font =
            Font::from_bytes(font_data, FontSettings::default()).expect("Failed to parse font");

        // Characters to pre-rasterize
        let chars: Vec<char> = (32u8..=126u8).map(|c| c as char).collect();

        // First pass: rasterize all glyphs to get their sizes
        let rasterized: Vec<(char, fontdue::Metrics, Vec<u8>)> = chars
            .iter()
            .map(|&c| {
                let (metrics, bitmap) = font.rasterize(c, size);
                (c, metrics, bitmap)
            })
            .collect();

        // Calculate atlas dimensions using a simple row packing
        let padding = 1u32;
        let mut atlas_width = 512u32;
        let mut atlas_height = 512u32;

        // Try to fit everything, increase size if needed
        loop {
            let mut x = padding;
            let mut y = padding;
            let mut row_height = 0u32;
            let mut fits = true;

            for (_, metrics, _) in &rasterized {
                let glyph_w = metrics.width as u32;
                let glyph_h = metrics.height as u32;

                if x + glyph_w + padding > atlas_width {
                    x = padding;
                    y += row_height + padding;
                    row_height = 0;
                }

                if y + glyph_h + padding > atlas_height {
                    fits = false;
                    break;
                }

                x += glyph_w + padding;
                row_height = row_height.max(glyph_h);
            }

            if fits {
                break;
            }

            // Double the smaller dimension
            if atlas_width <= atlas_height {
                atlas_width *= 2;
            } else {
                atlas_height *= 2;
            }
        }

        // Create atlas bitmap
        let mut atlas_data = vec![0u8; (atlas_width * atlas_height) as usize];
        let mut glyphs = HashMap::new();

        let mut x = padding;
        let mut y = padding;
        let mut row_height = 0u32;

        for (c, metrics, bitmap) in &rasterized {
            let glyph_w = metrics.width as u32;
            let glyph_h = metrics.height as u32;

            // Move to next row if needed
            if x + glyph_w + padding > atlas_width {
                x = padding;
                y += row_height + padding;
                row_height = 0;
            }

            // Copy glyph bitmap to atlas
            for gy in 0..glyph_h {
                for gx in 0..glyph_w {
                    let src_idx = (gy * glyph_w + gx) as usize;
                    let dst_idx = ((y + gy) * atlas_width + (x + gx)) as usize;
                    atlas_data[dst_idx] = bitmap[src_idx];
                }
            }

            // Store glyph info with normalized UVs
            let uv = [
                x as f32 / atlas_width as f32,
                y as f32 / atlas_height as f32,
                glyph_w as f32 / atlas_width as f32,
                glyph_h as f32 / atlas_height as f32,
            ];

            glyphs.insert(
                *c,
                GlyphInfo {
                    uv,
                    width: glyph_w,
                    height: glyph_h,
                    offset_x: metrics.xmin as f32,
                    offset_y: metrics.ymin as f32,
                    advance: metrics.advance_width,
                },
            );

            x += glyph_w + padding;
            row_height = row_height.max(glyph_h);
        }

        // Create GPU texture
        let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
            label: Some("Font Atlas"),
            size: wgpu::Extent3d {
                width: atlas_width,
                height: atlas_height,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::R8Unorm,
            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
            view_formats: &[],
        });

        gpu.queue.write_texture(
            wgpu::TexelCopyTextureInfo {
                texture: &texture,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            &atlas_data,
            wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(atlas_width),
                rows_per_image: Some(atlas_height),
            },
            wgpu::Extent3d {
                width: atlas_width,
                height: atlas_height,
                depth_or_array_layers: 1,
            },
        );

        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());

        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("Font Sampler"),
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            address_mode_w: wgpu::AddressMode::ClampToEdge,
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            ..Default::default()
        });

        // Calculate line height from font metrics
        let line_metrics = font.horizontal_line_metrics(size);
        let line_height = line_metrics.map(|m| m.new_line_size).unwrap_or(size * 1.2);

        Self {
            texture,
            view,
            sampler,
            glyphs,
            font,
            size,
            line_height,
        }
    }

    /// Returns glyph information for a character.
    ///
    /// Returns `None` if the character is not in the atlas (i.e., not in ASCII 32-126).
    /// For missing glyphs, callers typically substitute a fallback like `'?'` or skip
    /// rendering.
    #[inline]
    pub fn glyph(&self, c: char) -> Option<&GlyphInfo> {
        self.glyphs.get(&c)
    }

    /// Returns the font size in pixels that this atlas was rasterized at.
    ///
    /// Text should be rendered at this size for optimal quality. Scaling the rendered
    /// quads will work but may appear blurry or pixelated.
    #[inline]
    pub fn size(&self) -> f32 {
        self.size
    }

    /// Returns the recommended line height for this font.
    ///
    /// This value comes from the font's horizontal line metrics and represents the
    /// distance between baselines for multi-line text. Falls back to `size * 1.2`
    /// if the font doesn't provide line metrics.
    #[inline]
    pub fn line_height(&self) -> f32 {
        self.line_height
    }

    /// Measures the total horizontal advance width of a string.
    ///
    /// This sums the advance widths of all characters in the string, giving the
    /// cursor position after rendering the full text. Characters not in the atlas
    /// are skipped (contribute zero width).
    ///
    /// # Note
    ///
    /// This does not account for kerning pairs. For precise text layout, a more
    /// sophisticated text shaping solution would be needed.
    pub fn measure(&self, text: &str) -> f32 {
        text.chars()
            .filter_map(|c| self.glyphs.get(&c))
            .map(|g| g.advance)
            .sum()
    }
}

/// Built-in embedded font data (JetBrains Mono Regular).
///
/// This font is compiled into the binary using `include_bytes!`, eliminating
/// runtime file dependencies. JetBrains Mono is used for its excellent readability
/// at small sizes, making it ideal for debug overlays and developer UI.
const EMBEDDED_FONT: &[u8] = include_bytes!("fonts/JetBrainsMono-Regular.ttf");

/// Asset manager for loading and caching resources.
///
/// The `Assets` struct is the central hub for loading and managing game resources.
/// Currently focused on font management, it provides methods to load fonts from
/// files or raw bytes, and access them via lightweight [`FontId`] handles.
///
/// # Font Caching
///
/// Fonts are stored in an `Arc` to allow shared access from multiple renderers.
/// Each font loaded at a different size creates a separate atlas (no runtime scaling).
///
/// # Example
///
/// ```ignore
/// let mut assets = Assets::new();
///
/// // Load fonts at initialization (requires GPU context)
/// let title_font = assets.load_font(&gpu, "fonts/title.ttf", 48.0);
/// let body_font = assets.load_font(&gpu, "fonts/body.ttf", 16.0);
/// let debug_font = assets.default_font(&gpu, 12.0);
///
/// // Use fonts for rendering
/// if let Some(atlas) = assets.font(title_font) {
///     // Render text using atlas.glyph(), atlas.measure(), etc.
/// }
/// ```
pub struct Assets {
    /// Loaded font atlases, indexed by [`FontId`].
    pub(crate) fonts: Vec<Arc<FontAtlas>>,
}

impl Assets {
    /// Creates a new asset manager.
    pub(crate) fn new() -> Self {
        Self { fonts: Vec::new() }
    }

    /// Loads a font from a file path.
    ///
    /// Reads the font file from disk and creates a [`FontAtlas`] at the specified size.
    /// The returned [`FontId`] can be used to retrieve the atlas for rendering.
    ///
    /// # Arguments
    ///
    /// * `gpu` - GPU context for creating the font atlas texture
    /// * `path` - Path to a TTF or OTF font file
    /// * `size` - Font size in pixels
    ///
    /// # Panics
    ///
    /// Panics if the file cannot be read or the font data is invalid.
    ///
    /// # Example
    ///
    /// ```ignore
    /// let font_id = assets.load_font(&gpu, "assets/fonts/Roboto.ttf", 24.0);
    /// ```
    pub fn load_font(&mut self, gpu: &GpuContext, path: impl AsRef<Path>, size: f32) -> FontId {
        let data = std::fs::read(path.as_ref()).expect("Failed to read font file");
        self.load_font_bytes(gpu, &data, size)
    }

    /// Loads a font from raw TTF/OTF bytes.
    ///
    /// Useful for fonts embedded in the binary or loaded from custom sources
    /// (archives, network, etc.).
    ///
    /// # Arguments
    ///
    /// * `gpu` - GPU context for creating the font atlas texture
    /// * `data` - Raw bytes of a TTF or OTF font file
    /// * `size` - Font size in pixels
    ///
    /// # Panics
    ///
    /// Panics if the font data cannot be parsed.
    pub fn load_font_bytes(&mut self, gpu: &GpuContext, data: &[u8], size: f32) -> FontId {
        let atlas = FontAtlas::new(gpu, data, size);
        let id = FontId(self.fonts.len());
        self.fonts.push(Arc::new(atlas));
        id
    }

    /// Gets or loads the default embedded font at the specified size.
    ///
    /// Uses the built-in JetBrains Mono font, which is ideal for debug text,
    /// developer consoles, and other UI elements requiring a monospace font.
    ///
    /// # Arguments
    ///
    /// * `gpu` - GPU context for creating the font atlas texture
    /// * `size` - Font size in pixels
    ///
    /// # Note
    ///
    /// Currently creates a new atlas for each call. For repeated use at the same
    /// size, store the returned [`FontId`] rather than calling this repeatedly.
    ///
    /// # Example
    ///
    /// ```ignore
    /// // Load once during initialization
    /// let debug_font = assets.default_font(&gpu, 14.0);
    ///
    /// // Reuse the ID for rendering
    /// let atlas = assets.font(debug_font).unwrap();
    /// ```
    pub fn default_font(&mut self, gpu: &GpuContext, size: f32) -> FontId {
        self.load_font_bytes(gpu, EMBEDDED_FONT, size)
    }

    /// Retrieves a font atlas by its ID.
    ///
    /// Returns `None` if the ID is invalid (e.g., from a different `Assets` instance
    /// or after the assets were cleared).
    ///
    /// The returned `Arc<FontAtlas>` can be cloned cheaply for use across multiple
    /// render passes or threads.
    #[inline]
    pub fn font(&self, id: FontId) -> Option<Arc<FontAtlas>> {
        self.fonts.get(id.0).cloned()
    }
}