diff --git a/src/game.rs b/src/game.rs index d5825b7..f9728c0 100644 --- a/src/game.rs +++ b/src/game.rs @@ -18,10 +18,10 @@ use crate::systems::{self, ghost_collision_system, present_system, Hidden, Linea use crate::systems::{ audio_system, blinking_system, collision_system, debug_render_system, directional_render_system, dirty_render_system, eaten_ghost_system, ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, - render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Collider, DebugFontResource, DebugState, - DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, - GhostCollider, GlobalState, ItemBundle, ItemCollider, MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, - Renderable, ScoreResource, StartupSequence, SystemTimings, + render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, + DeltaTime, DirectionalAnimation, EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, + ItemBundle, ItemCollider, MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, + StartupSequence, SystemTimings, }; use crate::texture::animated::{DirectionalTiles, TileSequence}; use crate::texture::sprite::AtlasTile; @@ -42,6 +42,7 @@ use crate::{ asset::{get_asset_bytes, Asset}, events::GameCommand, map::render::MapRenderer, + systems::debug::TtfAtlasResource, systems::input::{Bindings, CursorPosition}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; @@ -162,12 +163,17 @@ impl Game { debug_texture.set_blend_mode(BlendMode::Blend); debug_texture.set_scale_mode(ScaleMode::Nearest); + // Create debug text atlas for efficient debug rendering let font_data: &'static [u8] = get_asset_bytes(Asset::Font)?.to_vec().leak(); let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?; let debug_font = ttf_context .load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE) .map_err(|e| GameError::Sdl(e.to_string()))?; + let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(&texture_creator, &debug_font)?; + // Populate the atlas with actual character data + ttf_atlas.populate_atlas(&mut canvas, &texture_creator, &debug_font)?; + // Initialize audio system let audio = crate::audio::Audio::new(); @@ -315,7 +321,7 @@ impl Game { world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(MapTextureResource(map_texture)); world.insert_non_send_resource(DebugTextureResource(debug_texture)); - world.insert_non_send_resource(DebugFontResource(debug_font)); + world.insert_non_send_resource(TtfAtlasResource(ttf_atlas)); world.insert_non_send_resource(AudioResource(audio)); world.add_observer( diff --git a/src/systems/debug.rs b/src/systems/debug.rs index e4c25f1..1614b87 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -4,14 +4,14 @@ use std::cmp::Ordering; use crate::constants::BOARD_PIXEL_OFFSET; use crate::map::builder::Map; use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings}; +use crate::texture::ttf::{TtfAtlas, TtfRenderer}; use bevy_ecs::resource::Resource; use bevy_ecs::system::{NonSendMut, Query, Res}; use glam::{IVec2, UVec2, Vec2}; use sdl2::pixels::Color; use sdl2::rect::{Point, Rect}; -use sdl2::render::{Canvas, Texture, TextureCreator}; -use sdl2::ttf::Font; -use sdl2::video::{Window, WindowContext}; +use sdl2::render::{Canvas, Texture}; +use sdl2::video::Window; #[derive(Resource, Default, Debug, Copy, Clone)] pub struct DebugState { @@ -25,31 +25,31 @@ fn f32_to_u8(value: f32) -> u8 { /// Resource to hold the debug texture for persistent rendering pub struct DebugTextureResource(pub Texture); -/// Resource to hold the debug font -pub struct DebugFontResource(pub Font<'static, 'static>); +/// Resource to hold the TTF text atlas +pub struct TtfAtlasResource(pub TtfAtlas); /// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset) fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 { ((pos + BOARD_PIXEL_OFFSET.as_vec2()) * scale).as_ivec2() } -/// Renders timing information in the top-left corner of the screen +/// Renders timing information in the top-left corner of the screen using the debug text atlas fn render_timing_display( canvas: &mut Canvas, - texture_creator: &mut TextureCreator, timings: &SystemTimings, - font: &Font, + text_renderer: &TtfRenderer, + atlas: &mut TtfAtlas, ) { // Format timing information using the formatting module let lines = timings.format_timing_display(); - let line_height = 14; // Approximate line height for 12pt font + let line_height = text_renderer.text_height(atlas) as i32 + 2; // Add 2px line spacing let padding = 10; // Calculate background dimensions let max_width = lines .iter() .filter(|l| !l.is_empty()) // Don't consider empty lines for width - .map(|line| font.size_of(line).unwrap().0) + .map(|line| text_renderer.text_width(atlas, line)) .max() .unwrap_or(0); @@ -75,14 +75,14 @@ fn render_timing_display( continue; } - // Render each line - let surface = font.render(line).blended(Color::RGBA(255, 255, 255, 200)).unwrap(); - let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); - // Position each line below the previous one - let y_pos = padding + (i * line_height) as i32; - let dest = Rect::new(padding, y_pos, texture.query().width, texture.query().height); - canvas.copy(&texture, None, dest).unwrap(); + let y_pos = padding + (i as i32 * line_height); + let position = Vec2::new(padding as f32, y_pos as f32); + + // Render the line using the debug text renderer + text_renderer + .render_text(canvas, atlas, line, position, Color::RGBA(255, 255, 255, 200)) + .unwrap(); } } @@ -90,7 +90,7 @@ fn render_timing_display( pub fn debug_render_system( mut canvas: NonSendMut<&mut Canvas>, mut debug_texture: NonSendMut, - debug_font: NonSendMut, + mut ttf_atlas: NonSendMut, debug_state: Res, timings: Res, map: Res, @@ -103,9 +103,8 @@ pub fn debug_render_system( let scale = (UVec2::from(canvas.output_size().unwrap()).as_vec2() / UVec2::from(canvas.logical_size()).as_vec2()).min_element(); - // Get texture creator before entering the closure to avoid borrowing conflicts - let mut texture_creator = canvas.texture_creator(); - let font = &debug_font.0; + // Create debug text renderer + let text_renderer = TtfRenderer::new(1.0); let cursor_world_pos = match *cursor { CursorPosition::None => None, @@ -188,20 +187,25 @@ pub fn debug_render_system( let node = map.graph.get_node(closest_node_id as NodeId).unwrap(); let pos = transform_position_with_offset(node.position, scale); - let surface = font - .render(&closest_node_id.to_string()) - .blended(Color { - a: f32_to_u8(0.4), - ..Color::WHITE - }) + let node_id_text = closest_node_id.to_string(); + let text_pos = Vec2::new((pos.x + 10) as f32, (pos.y - 5) as f32); + + text_renderer + .render_text( + debug_canvas, + &mut ttf_atlas.0, + &node_id_text, + text_pos, + Color { + a: f32_to_u8(0.4), + ..Color::WHITE + }, + ) .unwrap(); - let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); - let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height); - debug_canvas.copy(&texture, None, dest).unwrap(); } // Render timing information in the top-left corner - render_timing_display(debug_canvas, &mut texture_creator, &timings, font); + render_timing_display(debug_canvas, &timings, &text_renderer, &mut ttf_atlas.0); }) .unwrap(); } diff --git a/src/texture/mod.rs b/src/texture/mod.rs index 8d58c93..ddc368c 100644 --- a/src/texture/mod.rs +++ b/src/texture/mod.rs @@ -2,3 +2,4 @@ pub mod animated; pub mod blinking; pub mod sprite; pub mod text; +pub mod ttf; diff --git a/src/texture/ttf.rs b/src/texture/ttf.rs new file mode 100644 index 0000000..8003795 --- /dev/null +++ b/src/texture/ttf.rs @@ -0,0 +1,272 @@ +//! TTF font rendering using pre-rendered character atlas. +//! +//! This module provides efficient TTF font rendering by pre-rendering all needed +//! characters into a texture atlas at startup, avoiding expensive SDL2 font +//! surface-to-texture conversions every frame. + +use glam::{UVec2, Vec2}; +use sdl2::pixels::Color; +use sdl2::rect::Rect; +use sdl2::render::{Canvas, RenderTarget, Texture, TextureCreator}; + +use sdl2::ttf::Font; +use sdl2::video::WindowContext; +use std::collections::HashMap; + +use crate::error::{GameError, TextureError}; + +/// Character atlas tile representing a single rendered character +#[derive(Clone, Copy, Debug)] +pub struct TtfCharTile { + pub pos: UVec2, + pub size: UVec2, + pub advance: u32, // Character advance width for proportional fonts +} + +/// TTF text atlas containing pre-rendered characters for efficient rendering +pub struct TtfAtlas { + /// The texture containing all rendered characters + texture: Texture, + /// Mapping from character to its position and size in the atlas + char_tiles: HashMap, + /// Cached color modulation state to avoid redundant SDL2 calls + last_modulation: Option, +} + +const TTF_CHARS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:-/()ms μµ%± "; + +impl TtfAtlas { + /// Creates a new TTF text atlas by pre-rendering all needed characters. + /// + /// This should be called once at startup. It renders all characters that might + /// be used in text rendering into a single texture atlas for efficient GPU rendering. + pub fn new(texture_creator: &TextureCreator, font: &Font) -> Result { + // Calculate character dimensions and advance widths for proportional fonts + let mut char_tiles = HashMap::new(); + let mut max_height = 0u32; + let mut total_width = 0u32; + let mut char_metrics = Vec::new(); + + // First pass: measure all characters + for c in TTF_CHARS.chars() { + if c == ' ' { + // Handle space character specially - measure a non-space character for height + let space_height = font.size_of("0").map_err(|e| GameError::Sdl(e.to_string()))?.1; + let space_advance = font.size_of(" ").map_err(|e| GameError::Sdl(e.to_string()))?.0; + char_tiles.insert( + c, + TtfCharTile { + pos: UVec2::ZERO, // Will be set during population + size: UVec2::new(0, space_height), // Space has no visual content + advance: space_advance, + }, + ); + max_height = max_height.max(space_height); + char_metrics.push((c, 0, space_height, space_advance)); + } else { + let (advance, height) = font.size_of(&c.to_string()).map_err(|e| GameError::Sdl(e.to_string()))?; + char_tiles.insert( + c, + TtfCharTile { + pos: UVec2::ZERO, // Will be set during population + size: UVec2::new(advance, height), + advance, + }, + ); + max_height = max_height.max(height); + total_width += advance; + char_metrics.push((c, advance, height, advance)); + } + } + + // Calculate atlas dimensions (pack characters horizontally for better space utilization) + let atlas_size = UVec2::new(total_width, max_height); + + // Create atlas texture as a render target + let mut atlas_texture = texture_creator + .create_texture_target(None, atlas_size.x, atlas_size.y) + .map_err(|e| GameError::Sdl(e.to_string()))?; + atlas_texture.set_blend_mode(sdl2::render::BlendMode::Blend); + + // Second pass: calculate positions + let mut current_x = 0u32; + for (c, width, _height, _advance) in char_metrics { + if let Some(tile) = char_tiles.get_mut(&c) { + tile.pos = UVec2::new(current_x, 0); + current_x += width; + } + } + + Ok(Self { + texture: atlas_texture, + char_tiles, + last_modulation: None, + }) + } + + /// Renders all characters to the atlas texture using a canvas. + /// This must be called after creation to populate the atlas. + pub fn populate_atlas( + &mut self, + canvas: &mut Canvas, + texture_creator: &TextureCreator, + font: &Font, + ) -> Result<(), GameError> { + let mut render_error: Option = None; + + let result = canvas.with_texture_canvas(&mut self.texture, |atlas_canvas| { + // Clear with transparent background + atlas_canvas.set_draw_color(Color::RGBA(0, 0, 0, 0)); + atlas_canvas.clear(); + + for c in TTF_CHARS.chars() { + if c == ' ' { + // Skip rendering space character - it has no visual content + continue; + } + + // Render character to surface + let surface = match font.render(&c.to_string()).blended(Color::WHITE) { + Ok(s) => s, + Err(e) => { + render_error = Some(GameError::Sdl(e.to_string())); + return; + } + }; + + // Create texture from surface + let char_texture = match texture_creator.create_texture_from_surface(&surface) { + Ok(t) => t, + Err(e) => { + render_error = Some(GameError::Sdl(e.to_string())); + return; + } + }; + + // Get character tile info + let tile = match self.char_tiles.get(&c) { + Some(t) => t, + None => { + render_error = Some(GameError::Sdl(format!("Character '{}' not found in atlas tiles", c))); + return; + } + }; + + // Copy character to atlas + let dest = Rect::new(tile.pos.x as i32, tile.pos.y as i32, tile.size.x, tile.size.y); + if let Err(e) = atlas_canvas.copy(&char_texture, None, dest) { + render_error = Some(GameError::Sdl(e.to_string())); + return; + } + } + }); + + // Check the result of with_texture_canvas and any render error + if let Err(e) = result { + return Err(GameError::Sdl(e.to_string())); + } + + if let Some(error) = render_error { + return Err(error); + } + + Ok(()) + } + + /// Gets a character tile from the atlas + pub fn get_char_tile(&self, c: char) -> Option<&TtfCharTile> { + self.char_tiles.get(&c) + } +} + +/// TTF text renderer that uses the pre-rendered character atlas +pub struct TtfRenderer { + scale: f32, +} + +impl TtfRenderer { + pub fn new(scale: f32) -> Self { + Self { scale } + } + + /// Renders a string of text at the given position with the specified color + pub fn render_text( + &self, + canvas: &mut Canvas, + atlas: &mut TtfAtlas, + text: &str, + position: Vec2, + color: Color, + ) -> Result<(), TextureError> { + let mut x_offset = 0.0; + + // Apply color modulation once at the beginning if needed + if atlas.last_modulation != Some(color) { + atlas.texture.set_color_mod(color.r, color.g, color.b); + atlas.texture.set_alpha_mod(color.a); + atlas.last_modulation = Some(color); + } + + for c in text.chars() { + // Get character tile info first to avoid borrowing conflicts + let char_tile = atlas.get_char_tile(c); + + if let Some(char_tile) = char_tile { + if char_tile.size.x > 0 && char_tile.size.y > 0 { + // Only render non-space characters + let dest = Rect::new( + (position.x + x_offset) as i32, + position.y as i32, + (char_tile.size.x as f32 * self.scale) as u32, + (char_tile.size.y as f32 * self.scale) as u32, + ); + + // Render the character directly + let src = Rect::new( + char_tile.pos.x as i32, + char_tile.pos.y as i32, + char_tile.size.x, + char_tile.size.y, + ); + canvas.copy(&atlas.texture, src, dest).map_err(TextureError::RenderFailed)?; + } + + // Advance by character advance width (proportional spacing) + x_offset += char_tile.advance as f32 * self.scale; + } else { + // Fallback for unsupported characters - use a reasonable default + x_offset += 8.0 * self.scale; + } + } + + Ok(()) + } + + /// Calculate the width of a text string in pixels + pub fn text_width(&self, atlas: &TtfAtlas, text: &str) -> u32 { + let mut total_width = 0u32; + + for c in text.chars() { + if let Some(char_tile) = atlas.get_char_tile(c) { + total_width += (char_tile.advance as f32 * self.scale) as u32; + } else { + // Fallback for unsupported characters + total_width += (8.0 * self.scale) as u32; + } + } + + total_width + } + + /// Calculate the height of text in pixels + pub fn text_height(&self, atlas: &TtfAtlas) -> u32 { + // Find the maximum height among all characters + atlas + .char_tiles + .values() + .map(|tile| tile.size.y) + .max() + .unwrap_or(0) + .saturating_mul(self.scale as u32) + } +}