diff --git a/src/render/font_atlas.rs b/src/render/font_atlas.rs index 3975902..c263303 100644 --- a/src/render/font_atlas.rs +++ b/src/render/font_atlas.rs @@ -10,7 +10,7 @@ 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; +const FONT_PX: f32 = 124.0; struct GlyphMetrics { @@ -58,7 +58,10 @@ impl FontAtlas 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_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, @@ -129,8 +132,8 @@ impl FontAtlas 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, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, ..Default::default() }); @@ -156,6 +159,24 @@ impl FontAtlas /// `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 + /// Returns `(n_lines, max_chars_in_any_line)` for the given text wrapped at `inner_half_w`. + pub fn measure_text(&self, text: &str, char_world_h: f32, inner_half_w: f32) -> (usize, usize) + { + 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 (1, 0); + } + + let lines = word_wrap(text, chars_per_line); + let n_lines = lines.len().max(1); + let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); + + (n_lines, max_chars) + } + pub fn build_bubble_text( &self, text: &str, @@ -214,12 +235,18 @@ impl FontAtlas 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: 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: br.to_array(), + uv: g.uv_max, + }, TextVertex { position: bl.to_array(), uv: [g.uv_min[0], g.uv_max[1]], diff --git a/src/render/global.rs b/src/render/global.rs index 5227c0a..cd8bcf3 100644 --- a/src/render/global.rs +++ b/src/render/global.rs @@ -9,8 +9,8 @@ use std::cell::RefCell; use crate::debug::DebugMode; use crate::entity::EntityHandle; -use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight}; use super::text_pipeline::TextVertex; +use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight}; thread_local! { static GLOBAL_RENDERER: RefCell> = RefCell::new(None); diff --git a/src/render/mod.rs b/src/render/mod.rs index ff43890..354e9dc 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -15,7 +15,8 @@ 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_font_atlas, 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}; diff --git a/src/render/text_pipeline.rs b/src/render/text_pipeline.rs index 821f6e8..cccaf24 100644 --- a/src/render/text_pipeline.rs +++ b/src/render/text_pipeline.rs @@ -65,40 +65,40 @@ impl TextPipeline 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, + 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::< + ViewProjUniform, + >() + as u64), }, - 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, + 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, }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); + 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"), diff --git a/src/shaders/text.wgsl b/src/shaders/text.wgsl index 24447d0..3531505 100644 --- a/src/shaders/text.wgsl +++ b/src/shaders/text.wgsl @@ -37,9 +37,9 @@ fn vs_main(in: VertexIn) -> VertexOut fn fs_main(in: VertexOut) -> @location(0) vec4 { let coverage = textureSample(glyph_atlas, glyph_sampler, in.uv).r; - if coverage < 0.5 + if coverage < 0.004 { discard; } - return vec4(1.0, 1.0, 1.0, 1.0); + return vec4(1.0, 1.0, 1.0, coverage); } diff --git a/src/systems/dialog_render.rs b/src/systems/dialog_render.rs index 62b38ae..21778d3 100644 --- a/src/systems/dialog_render.rs +++ b/src/systems/dialog_render.rs @@ -4,9 +4,9 @@ use crate::render::billboard::{BillboardDrawCall, BillboardVertex, BubbleUniform use crate::render::{with_font_atlas, TextVertex}; use crate::world::World; -const BUBBLE_WIDTH: f32 = 2.2; -const BUBBLE_HEIGHT: f32 = 1.1; -const BODY_FRAC: f32 = 0.78; +const MAX_BUBBLE_WIDTH: f32 = 8.0; +const MIN_BUBBLE_WIDTH: f32 = 0.5; +const TAIL_HEIGHT: f32 = 0.242; const CORNER_R: f32 = 0.18; const BORDER_W: f32 = 0.06; const HEIGHT_OFFSET: f32 = 8.2; @@ -14,7 +14,7 @@ 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 CHAR_WORLD_HEIGHT: f32 = 0.84; const TEXT_PADDING: f32 = 0.06; const LINE_SPACING: f32 = 0.01; @@ -43,60 +43,6 @@ pub fn dialog_bubble_render_system( None => continue, }; - let body_half_h = BUBBLE_HEIGHT * BODY_FRAC * 0.5; - let tail_height = BUBBLE_HEIGHT * (1.0 - BODY_FRAC); - let anchor = character_pos + Vec3::Y * (HEIGHT_OFFSET + body_half_h); - - let to_camera = camera_pos - anchor; - let forward = if to_camera.length_squared() > 1e-6 - { - to_camera.normalize() - } - else - { - Vec3::Z - }; - - let up_ref = Vec3::Y; - let right = if forward.abs().dot(up_ref) > 0.99 - { - Vec3::X - } - else - { - up_ref.cross(forward).normalize() - }; - let up = forward.cross(right).normalize(); - - let half_w = BUBBLE_WIDTH * 0.5; - let total_down = body_half_h + tail_height; - - 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] }, - ]; - - let uniforms = BubbleUniforms { - view_proj: view_proj.to_cols_array_2d(), - size: [BUBBLE_WIDTH, BUBBLE_HEIGHT], - body_frac: BODY_FRAC, - corner_r: CORNER_R, - border_w: BORDER_W, - _pad1: [0.0; 3], - fill_color: FILL_COLOR, - border_color: BORDER_COLOR, - _pad2: [0.0; 32], - }; - - calls.push(BillboardDrawCall { vertices, uniforms }); - let text = match world .dialog_bubbles .with(bubble_entity, |b| b.current_text.clone()) @@ -105,12 +51,101 @@ pub fn dialog_bubble_render_system( 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 (draw_call, text_verts) = with_font_atlas(|atlas| { + let char_w = CHAR_WORLD_HEIGHT * atlas.aspect(); - let text_verts = with_font_atlas(|atlas| { - atlas.build_bubble_text( + // First-pass: measure text at max width using a conservative padding estimate. + // BORDER_W is applied against body_height (the smaller dimension); TAIL_HEIGHT + // is a safe lower-bound for body_height, giving a slight over-estimate of padding. + let approx_pad = BORDER_W * TAIL_HEIGHT + TEXT_PADDING; + let max_inner_half_w = ((MAX_BUBBLE_WIDTH - 2.0 * approx_pad) * 0.5).max(char_w); + let (n_lines, max_line_chars) = + atlas.measure_text(&text, CHAR_WORLD_HEIGHT, max_inner_half_w); + + // Compute body height analytically. + // border_world = BORDER_W * body_height (body is the smaller dimension) + // body_height = text_h + 2 * (border_world + TEXT_PADDING) + // → body_height * (1 - 2*BORDER_W) = text_h + 2*TEXT_PADDING + let text_h = n_lines as f32 * CHAR_WORLD_HEIGHT + + n_lines.saturating_sub(1) as f32 * LINE_SPACING; + let body_height = (text_h + 2.0 * TEXT_PADDING) / (1.0 - 2.0 * BORDER_W); + + // Bubble width: fit the longest line exactly, then clamp to [MIN, MAX]. + let border_world = BORDER_W * body_height; + let needed_width = max_line_chars as f32 * char_w + 2.0 * (border_world + TEXT_PADDING); + let bubble_width = needed_width.clamp(MIN_BUBBLE_WIDTH, MAX_BUBBLE_WIDTH); + + let bubble_height = body_height + TAIL_HEIGHT; + let body_frac = body_height / bubble_height; + + // Billboard orientation + let body_half_h = body_height * 0.5; + let anchor = character_pos + Vec3::Y * (HEIGHT_OFFSET + body_half_h); + + let to_camera = camera_pos - anchor; + let forward = if to_camera.length_squared() > 1e-6 + { + to_camera.normalize() + } + else + { + Vec3::Z + }; + + let up_ref = Vec3::Y; + let right = if forward.abs().dot(up_ref) > 0.99 + { + Vec3::X + } + else + { + up_ref.cross(forward).normalize() + }; + let up = forward.cross(right).normalize(); + + let half_w = bubble_width * 0.5; + let total_down = body_half_h + TAIL_HEIGHT; + + 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], + }, + ]; + + let uniforms = BubbleUniforms { + view_proj: view_proj.to_cols_array_2d(), + size: [bubble_width, bubble_height], + body_frac, + corner_r: CORNER_R, + border_w: BORDER_W, + _pad1: [0.0; 3], + fill_color: FILL_COLOR, + border_color: BORDER_COLOR, + _pad2: [0.0; 32], + }; + + 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 = atlas.build_bubble_text( &text, anchor, right, @@ -119,9 +154,12 @@ pub fn dialog_bubble_render_system( inner_top_y, CHAR_WORLD_HEIGHT, LINE_SPACING, - ) + ); + + (BillboardDrawCall { vertices, uniforms }, text_verts) }); + calls.push(draw_call); all_text.extend(text_verts); }