diff --git a/assets/fonts/DepartureMono-Regular.otf b/assets/fonts/DepartureMono-Regular.otf new file mode 100644 index 0000000..f9dc2e8 Binary files /dev/null and b/assets/fonts/DepartureMono-Regular.otf differ diff --git a/src/render/font_atlas.rs b/src/render/font_atlas.rs new file mode 100644 index 0000000..3975902 --- /dev/null +++ b/src/render/font_atlas.rs @@ -0,0 +1,264 @@ +use ab_glyph::{Font, FontVec, PxScale, ScaleFont}; +use glam::Vec3; + +use crate::paths; + +use super::text_pipeline::TextVertex; + +const ATLAS_COLS: u32 = 16; +const ATLAS_ROWS: u32 = 8; +const FIRST_CHAR: u32 = 32; +const LAST_CHAR: u32 = 126; +const NUM_GLYPHS: u32 = LAST_CHAR - FIRST_CHAR + 1; +const FONT_PX: f32 = 16.0; + +struct GlyphMetrics +{ + uv_min: [f32; 2], + uv_max: [f32; 2], +} + +pub struct FontAtlas +{ + pub texture_view: wgpu::TextureView, + pub sampler: wgpu::Sampler, + glyphs: Vec, + pub cell_w: f32, + pub cell_h: f32, +} + +impl FontAtlas +{ + pub fn load(device: &wgpu::Device, queue: &wgpu::Queue) -> Self + { + let path = paths::fonts::departure_mono(); + let data = std::fs::read(&path).unwrap_or_else(|_| panic!("Failed to read font: {path}")); + let font = FontVec::try_from_vec(data).expect("Failed to parse font"); + + let scale = PxScale::from(FONT_PX); + let scaled = font.as_scaled(scale); + + let ascent = scaled.ascent(); + let descent = scaled.descent(); + let cell_h = (ascent - descent).ceil() as u32; + let cell_w = scaled.h_advance(font.glyph_id(' ')).ceil() as u32; + + let atlas_w = ATLAS_COLS * cell_w; + let atlas_h = ATLAS_ROWS * cell_h; + + let mut pixels = vec![0u8; (atlas_w * atlas_h) as usize]; + let mut glyphs = Vec::with_capacity(NUM_GLYPHS as usize); + + for idx in 0..NUM_GLYPHS + { + let ch = char::from_u32(FIRST_CHAR + idx).unwrap_or(' '); + let col = idx % ATLAS_COLS; + let row = idx / ATLAS_COLS; + let cell_x = col * cell_w; + let cell_y = row * cell_h; + + glyphs.push(GlyphMetrics { + uv_min: [cell_x as f32 / atlas_w as f32, cell_y as f32 / atlas_h as f32], + uv_max: [ + (cell_x + cell_w) as f32 / atlas_w as f32, + (cell_y + cell_h) as f32 / atlas_h as f32, + ], + }); + + let glyph_id = font.glyph_id(ch); + let glyph = glyph_id.with_scale_and_position( + scale, + ab_glyph::point(cell_x as f32, cell_y as f32 + ascent), + ); + + if let Some(outlined) = font.outline_glyph(glyph) + { + let bounds = outlined.px_bounds(); + outlined.draw(|x, y, coverage| { + let abs_x = bounds.min.x.floor() as i32 + x as i32; + let abs_y = bounds.min.y.floor() as i32 + y as i32; + if abs_x >= 0 && abs_y >= 0 + { + let ax = abs_x as u32; + let ay = abs_y as u32; + if ax < atlas_w && ay < atlas_h + { + let i = (ay * atlas_w + ax) as usize; + let v = (coverage * 255.0 + 0.5) as u8; + pixels[i] = pixels[i].max(v); + } + } + }); + } + } + + let extent = wgpu::Extent3d { + width: atlas_w, + height: atlas_h, + depth_or_array_layers: 1, + }; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Font Atlas"), + size: extent, + 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: &[], + }); + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(atlas_w), + rows_per_image: Some(atlas_h), + }, + extent, + ); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Font Atlas Sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Self { + texture_view, + sampler, + glyphs, + cell_w: cell_w as f32, + cell_h: cell_h as f32, + } + } + + pub fn aspect(&self) -> f32 + { + self.cell_w / self.cell_h + } + + /// Build billboard-space text quads for a single dialog bubble. + /// + /// `anchor` – world-space centre of the bubble body + /// `right`/`up` – billboard orientation vectors + /// `inner_half_w` – half-width of the text area in world units + /// `inner_top_y` – y-offset (up-axis) of the top edge of the text area + /// `char_world_h` – world-space height of one character cell + /// `line_spacing` – extra vertical gap between lines in world units + pub fn build_bubble_text( + &self, + text: &str, + anchor: Vec3, + right: Vec3, + up: Vec3, + inner_half_w: f32, + inner_top_y: f32, + char_world_h: f32, + line_spacing: f32, + ) -> Vec + { + let char_w = char_world_h * self.aspect(); + let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize; + + if chars_per_line == 0 + { + return Vec::new(); + } + + let lines = word_wrap(text, chars_per_line); + let half_char_h = char_world_h * 0.5; + let half_char_w = char_w * 0.5; + let text_left = -inner_half_w; + + let mut verts = Vec::new(); + + for (row, line) in lines.iter().enumerate() + { + let y = inner_top_y - half_char_h - row as f32 * (char_world_h + line_spacing); + if y < -inner_top_y + half_char_h + { + break; + } + + for (col, ch) in line.chars().enumerate() + { + if ch == ' ' + { + continue; + } + + let code = ch as u32; + if code < FIRST_CHAR || code > LAST_CHAR + { + continue; + } + let g = &self.glyphs[(code - FIRST_CHAR) as usize]; + + let x = text_left + half_char_w + col as f32 * char_w; + let centre = anchor + right * x + up * y; + + let tl = centre - right * half_char_w + up * half_char_h; + let tr = centre + right * half_char_w + up * half_char_h; + let br = centre + right * half_char_w - up * half_char_h; + let bl = centre - right * half_char_w - up * half_char_h; + + verts.extend_from_slice(&[ + TextVertex { position: tl.to_array(), uv: g.uv_min }, + TextVertex { + position: tr.to_array(), + uv: [g.uv_max[0], g.uv_min[1]], + }, + TextVertex { position: br.to_array(), uv: g.uv_max }, + TextVertex { + position: bl.to_array(), + uv: [g.uv_min[0], g.uv_max[1]], + }, + ]); + } + } + + verts + } +} + +fn word_wrap(text: &str, chars_per_line: usize) -> Vec +{ + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() + { + if current.is_empty() + { + current.push_str(word); + } + else if current.len() + 1 + word.len() <= chars_per_line + { + current.push(' '); + current.push_str(word); + } + else + { + lines.push(current.clone()); + current = word.to_string(); + } + } + + if !current.is_empty() + { + lines.push(current); + } + + lines +} diff --git a/src/render/global.rs b/src/render/global.rs index dda7cb6..5227c0a 100644 --- a/src/render/global.rs +++ b/src/render/global.rs @@ -9,7 +9,8 @@ use std::cell::RefCell; use crate::debug::DebugMode; use crate::entity::EntityHandle; -use super::{BillboardDrawCall, DrawCall, Renderer, Spotlight}; +use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight}; +use super::text_pipeline::TextVertex; thread_local! { static GLOBAL_RENDERER: RefCell> = RefCell::new(None); @@ -99,6 +100,13 @@ pub fn set_selected_entity(entity: Option) with_mut(|r| r.selected_entity = entity); } +pub fn with_font_atlas(f: F) -> R +where + F: FnOnce(&FontAtlas) -> R, +{ + with_ref(|r| f(&r.font_atlas)) +} + pub fn render( view: &glam::Mat4, projection: &glam::Mat4, @@ -106,6 +114,7 @@ pub fn render( player_position: glam::Vec3, draw_calls: &[DrawCall], billboard_calls: &[BillboardDrawCall], + text_vertices: &[TextVertex], time: f32, delta_time: f32, debug_mode: DebugMode, @@ -119,6 +128,7 @@ pub fn render( player_position, draw_calls, billboard_calls, + text_vertices, time, delta_time, debug_mode, diff --git a/src/render/mod.rs b/src/render/mod.rs index 7e27324..ff43890 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -6,14 +6,18 @@ mod shadow; mod types; pub mod billboard; +pub mod font_atlas; pub mod snow; pub mod snow_light; +pub mod text_pipeline; pub use billboard::{BillboardDrawCall, BillboardPipeline}; +pub use font_atlas::FontAtlas; pub use global::{ aspect_ratio, init, init_snow_light_accumulation, render, set_selected_entity, set_snow_depth, - set_terrain_data, update_spotlights, with_device, with_queue, with_surface_format, + set_terrain_data, update_spotlights, with_device, with_font_atlas, with_queue, with_surface_format, }; +pub use text_pipeline::TextVertex; pub use types::{DrawCall, Pipeline, Spotlight, SpotlightRaw, Uniforms, MAX_SPOTLIGHTS}; use crate::entity::EntityHandle; @@ -38,6 +42,7 @@ pub struct Renderer pub config: wgpu::SurfaceConfiguration, framebuffer: LowResFramebuffer, + fullres_depth_view: wgpu::TextureView, standard_pipeline: wgpu::RenderPipeline, snow_clipmap_pipeline: wgpu::RenderPipeline, @@ -45,6 +50,8 @@ pub struct Renderer debug_lines_pipeline: Option, debug_overlay: Option, billboard_pipeline: BillboardPipeline, + font_atlas: font_atlas::FontAtlas, + text_pipeline: text_pipeline::TextPipeline, wireframe_supported: bool, uniform_buffer: wgpu::Buffer, @@ -148,6 +155,23 @@ impl Renderer let framebuffer = LowResFramebuffer::new(&device, low_res_width, low_res_height, config.format); + let fullres_depth_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Full Res Depth Texture"), + size: wgpu::Extent3d { + width: config.width, + height: config.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let fullres_depth_view = + fullres_depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Uniform Buffer"), size: (std::mem::size_of::() * MAX_DRAW_CALLS) as wgpu::BufferAddress, @@ -502,6 +526,8 @@ impl Renderer )); let billboard_pipeline = BillboardPipeline::new(&device, config.format); + let font_atlas = font_atlas::FontAtlas::load(&device, &queue); + let text_pipeline = text_pipeline::TextPipeline::new(&device, config.format, &font_atlas); let debug_overlay = Some(debug_overlay::DebugOverlay::new(&device, config.format)); @@ -526,12 +552,15 @@ impl Renderer surface, config, framebuffer, + fullres_depth_view, standard_pipeline, snow_clipmap_pipeline, wireframe_pipeline, debug_lines_pipeline, debug_overlay, billboard_pipeline, + font_atlas, + text_pipeline, wireframe_supported, uniform_buffer, bind_group_layout, @@ -576,6 +605,7 @@ impl Renderer player_position: glam::Vec3, draw_calls: &[DrawCall], billboard_calls: &[BillboardDrawCall], + text_vertices: &[text_pipeline::TextVertex], time: f32, delta_time: f32, debug_mode: DebugMode, @@ -954,14 +984,6 @@ impl Renderer } } - self.billboard_pipeline.render( - &mut encoder, - &self.queue, - &self.framebuffer.view, - &self.framebuffer.depth_view, - billboard_calls, - ); - self.queue.submit(std::iter::once(encoder.finish())); let frame = match self.surface.get_current_texture() @@ -1012,6 +1034,50 @@ impl Renderer } self.queue.submit(std::iter::once(blit_encoder.finish())); + + let mut overlay_encoder = + self.device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Dialog Overlay Encoder"), + }); + + { + let _depth_clear = overlay_encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Full Res Depth Clear"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.fullres_depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } + + self.billboard_pipeline.render( + &mut overlay_encoder, + &self.queue, + &screen_view, + &self.fullres_depth_view, + billboard_calls, + ); + + let view_proj = (*projection * *view).to_cols_array_2d(); + self.text_pipeline.render( + &mut overlay_encoder, + &self.queue, + &screen_view, + &self.fullres_depth_view, + text_vertices, + view_proj, + ); + + self.queue.submit(std::iter::once(overlay_encoder.finish())); frame } diff --git a/src/render/text_pipeline.rs b/src/render/text_pipeline.rs new file mode 100644 index 0000000..821f6e8 --- /dev/null +++ b/src/render/text_pipeline.rs @@ -0,0 +1,262 @@ +use bytemuck::{Pod, Zeroable}; +use wgpu::util::DeviceExt; + +use crate::paths; + +use super::font_atlas::FontAtlas; + +const MAX_TEXT_CHARS: usize = 512; + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +pub struct TextVertex +{ + pub position: [f32; 3], + pub uv: [f32; 2], +} + +impl TextVertex +{ + fn desc() -> wgpu::VertexBufferLayout<'static> + { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + ], + } + } +} + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct ViewProjUniform +{ + matrix: [[f32; 4]; 4], +} + +pub struct TextPipeline +{ + pipeline: wgpu::RenderPipeline, + vertex_buffer: wgpu::Buffer, + index_buffer: wgpu::Buffer, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +impl TextPipeline +{ + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, atlas: &FontAtlas) -> Self + { + let shader_src = + std::fs::read_to_string(&paths::shaders::text()).expect("Failed to read text.wgsl"); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Text Shader"), + source: wgpu::ShaderSource::Wgsl(shader_src.into()), + }); + + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Text Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Text ViewProj Buffer"), + size: std::mem::size_of::() as wgpu::BufferAddress, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Text Bind Group"), + layout: &bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&atlas.texture_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&atlas.sampler), + }, + ], + }); + + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Text Vertex Buffer"), + size: (MAX_TEXT_CHARS * 4 * std::mem::size_of::()) as wgpu::BufferAddress, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let indices: Vec = (0..MAX_TEXT_CHARS as u16) + .flat_map(|i| { + let b = i * 4; + [b, b + 1, b + 2, b + 2, b + 3, b] + }) + .collect(); + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Text Index Buffer"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Text Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Text Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[TextVertex::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + + Self { + pipeline, + vertex_buffer, + index_buffer, + uniform_buffer, + bind_group, + } + } + + pub fn render( + &self, + encoder: &mut wgpu::CommandEncoder, + queue: &wgpu::Queue, + color_view: &wgpu::TextureView, + depth_view: &wgpu::TextureView, + vertices: &[TextVertex], + view_proj: [[f32; 4]; 4], + ) + { + if vertices.is_empty() + { + return; + } + + let n_chars = (vertices.len() / 4).min(MAX_TEXT_CHARS); + let used = &vertices[..n_chars * 4]; + + queue.write_buffer( + &self.uniform_buffer, + 0, + bytemuck::cast_slice(&[ViewProjUniform { matrix: view_proj }]), + ); + queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(used)); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Text Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16); + pass.draw_indexed(0..(n_chars * 6) as u32, 0, 0..1); + } +} diff --git a/src/shaders/text.wgsl b/src/shaders/text.wgsl new file mode 100644 index 0000000..24447d0 --- /dev/null +++ b/src/shaders/text.wgsl @@ -0,0 +1,45 @@ +struct Uniforms +{ + view_proj: mat4x4, +} + +@group(0) @binding(0) +var u: Uniforms; + +@group(0) @binding(1) +var glyph_atlas: texture_2d; + +@group(0) @binding(2) +var glyph_sampler: sampler; + +struct VertexIn +{ + @location(0) position: vec3, + @location(1) uv: vec2, +} + +struct VertexOut +{ + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(in: VertexIn) -> VertexOut +{ + var out: VertexOut; + out.clip_pos = u.view_proj * vec4(in.position, 1.0); + out.uv = in.uv; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 +{ + let coverage = textureSample(glyph_atlas, glyph_sampler, in.uv).r; + if coverage < 0.5 + { + discard; + } + return vec4(1.0, 1.0, 1.0, 1.0); +} diff --git a/src/systems/dialog_render.rs b/src/systems/dialog_render.rs index d0eee82..62b38ae 100644 --- a/src/systems/dialog_render.rs +++ b/src/systems/dialog_render.rs @@ -1,6 +1,7 @@ use glam::{Mat4, Vec3}; use crate::render::billboard::{BillboardDrawCall, BillboardVertex, BubbleUniforms}; +use crate::render::{with_font_atlas, TextVertex}; use crate::world::World; const BUBBLE_WIDTH: f32 = 2.2; @@ -13,13 +14,18 @@ const HEIGHT_OFFSET: f32 = 8.2; const FILL_COLOR: [f32; 4] = [0.05, 0.05, 0.05, 1.0]; const BORDER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const CHAR_WORLD_HEIGHT: f32 = 0.36; +const TEXT_PADDING: f32 = 0.06; +const LINE_SPACING: f32 = 0.01; + pub fn dialog_bubble_render_system( world: &World, camera_pos: Vec3, view_proj: Mat4, -) -> Vec +) -> (Vec, Vec) { let mut calls = Vec::new(); + let mut all_text: Vec = Vec::new(); for bubble_entity in world.bubble_tags.all() { @@ -65,29 +71,16 @@ pub fn dialog_bubble_render_system( let half_w = BUBBLE_WIDTH * 0.5; let total_down = body_half_h + tail_height; - // Corners: tl → tr → br → bl (CCW in clip space when billboard faces camera). let tl = anchor - right * half_w + up * body_half_h; let tr = anchor + right * half_w + up * body_half_h; let br = anchor + right * half_w - up * total_down; let bl = anchor - right * half_w - up * total_down; let vertices = [ - BillboardVertex { - position: tl.to_array(), - uv: [0.0, 0.0], - }, - BillboardVertex { - position: tr.to_array(), - uv: [1.0, 0.0], - }, - BillboardVertex { - position: br.to_array(), - uv: [1.0, 1.0], - }, - BillboardVertex { - position: bl.to_array(), - uv: [0.0, 1.0], - }, + BillboardVertex { position: tl.to_array(), uv: [0.0, 0.0] }, + BillboardVertex { position: tr.to_array(), uv: [1.0, 0.0] }, + BillboardVertex { position: br.to_array(), uv: [1.0, 1.0] }, + BillboardVertex { position: bl.to_array(), uv: [0.0, 1.0] }, ]; let uniforms = BubbleUniforms { @@ -103,7 +96,34 @@ pub fn dialog_bubble_render_system( }; calls.push(BillboardDrawCall { vertices, uniforms }); + + let text = match world + .dialog_bubbles + .with(bubble_entity, |b| b.current_text.clone()) + { + Some(t) => t, + None => continue, + }; + + let border_world = BORDER_W * BUBBLE_WIDTH.min(BUBBLE_HEIGHT * BODY_FRAC); + let inner_half_w = BUBBLE_WIDTH * 0.5 - border_world - TEXT_PADDING; + let inner_top_y = body_half_h - border_world - TEXT_PADDING; + + let text_verts = with_font_atlas(|atlas| { + atlas.build_bubble_text( + &text, + anchor, + right, + up, + inner_half_w, + inner_top_y, + CHAR_WORLD_HEIGHT, + LINE_SPACING, + ) + }); + + all_text.extend(text_verts); } - calls + (calls, all_text) } diff --git a/src/systems/dialog_system.rs b/src/systems/dialog_system.rs index a058941..8d7a860 100644 --- a/src/systems/dialog_system.rs +++ b/src/systems/dialog_system.rs @@ -107,8 +107,6 @@ fn spawn_bubble(world: &mut World, character_entity: EntityHandle) display_time, }, ); - - println!("Dialog bubble spawned for character {character_entity}"); } fn despawn_bubbles_for_character(world: &mut World, character_entity: EntityHandle) @@ -136,7 +134,6 @@ fn despawn_bubbles_for_character(world: &mut World, character_entity: EntityHand } world.despawn(bubble_entity); - println!("Dialog bubble despawned for character {character_entity}"); } } @@ -216,11 +213,6 @@ fn tick_displaying_bubbles(world: &mut World, delta: f32) world.dialog_bubbles.with_mut(bubble_entity, |b| { b.phase = DialogPhase::ProjectileInFlight { projectile_entity }; }); - - println!( - "Dialog projectile spawned, correct parry: {:?}", - correct_parry - ); } } } @@ -282,19 +274,11 @@ fn process_outcomes(world: &mut World) timer: display_time, }; }); - println!( - "Dialog advanced: '{}'", - world - .dialog_bubbles - .with(bubble_entity, |b| b.current_text.clone()) - .unwrap_or_default() - ); } Some(None) => { world.despawn(bubble_entity); - println!("Dialog story finished"); } None =>