diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 4f437f407..bc8ee6ee3 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -3,6 +3,7 @@ pub mod hatch_batched_gpu; pub mod hatch_gpu; pub mod image_gpu; pub mod mesh_gpu; +pub mod text_gpu; pub mod uniforms; pub mod viewcube; pub mod wire_gpu; diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs new file mode 100644 index 000000000..56b6bc577 --- /dev/null +++ b/src/scene/pipeline/text_gpu.rs @@ -0,0 +1,358 @@ +//! GPU buffers for SDF text quads (Phase 2b of the text-shader initiative). +//! +//! Unlike images (one texture per entity), all glyphs share ONE atlas texture +//! and every glyph is a quad in a single instance-less vertex buffer, so the +//! whole layout draws in one call. Group 1 is just `{ atlas texture, sampler }`; +//! per-glyph colour and draw-order live in the vertices. +//! +//! Vertex positions use the same double-single relative-to-eye encoding as +//! wires/hatches/images so text stays precise at large drawing coordinates. + +// Not yet driven by the render loop — that hook-up (Pipeline fields, per-frame +// upload, a render pass, and suppressing the old stroke text) is the final +// integration step, done with the app running to verify pixels. +#![allow(dead_code)] + +use iced::wgpu; +use iced::wgpu::util::DeviceExt; + +use crate::scene::text::glyph_quads::GlyphQuad; +use crate::scene::text::sdf_atlas::GlyphAtlas; + +// ── Vertex ──────────────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct TextVertex { + pub pos: [f32; 3], + pub pos_low: [f32; 3], + pub uv: [f32; 2], + pub color: [f32; 4], + pub draw_depth: f32, +} // 52 bytes, no padding holes (all f32) + +impl TextVertex { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(TextVertex, pos) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(TextVertex, pos_low) as u64, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(TextVertex, uv) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(TextVertex, color) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(TextVertex, draw_depth) as u64, + shader_location: 4, + format: wgpu::VertexFormat::Float32, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: ATTRS, + } + } +} + +/// Split an f64 into the double-single (high f32, low residual f32) pair the +/// shaders reconstruct relative-to-eye. +fn split_ds(v: f64) -> (f32, f32) { + let high = v as f32; + (high, (v - high as f64) as f32) +} + +/// Append the two triangles (6 vertices) for each glyph quad of one text run +/// into `out`, placing the run-local quad corners into world space: +/// `world = corner * anno + origin`, then double-single split. +/// +/// UV mapping (atlas row 0 = top; quad corners are BL, BR, TR, TL): +/// BL -> (u_min, v_max) BR -> (u_max, v_max) +/// TR -> (u_max, v_min) TL -> (u_min, v_min) +pub fn push_glyph_vertices( + out: &mut Vec, + quads: &[GlyphQuad], + origin: [f64; 3], + anno: f64, + color: [f32; 4], + draw_depth: f32, +) { + for q in quads { + let mk = |ci: usize, uv: [f32; 2]| -> TextVertex { + let c = q.corners[ci]; + let wx = c[0] as f64 * anno + origin[0]; + let wy = c[1] as f64 * anno + origin[1]; + let wz = origin[2]; + let (xh, xl) = split_ds(wx); + let (yh, yl) = split_ds(wy); + let (zh, zl) = split_ds(wz); + TextVertex { + pos: [xh, yh, zh], + pos_low: [xl, yl, zl], + uv, + color, + draw_depth, + } + }; + let bl = [q.uv_min[0], q.uv_max[1]]; + let br = [q.uv_max[0], q.uv_max[1]]; + let tr = [q.uv_max[0], q.uv_min[1]]; + let tl = [q.uv_min[0], q.uv_min[1]]; + out.push(mk(0, bl)); + out.push(mk(1, br)); + out.push(mk(2, tr)); + out.push(mk(0, bl)); + out.push(mk(2, tr)); + out.push(mk(3, tl)); + } +} + +// ── Atlas texture ─────────────────────────────────────────────────────────── + +/// The shared glyph atlas on the GPU: a single-channel (R8) SDF texture plus +/// its sampler and bind group. +pub struct TextAtlasGpu { + pub bind_group: wgpu::BindGroup, + _texture: wgpu::Texture, + _sampler: wgpu::Sampler, +} + +impl TextAtlasGpu { + /// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`. + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("text.atlas.bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + 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: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) + } + + /// Upload the atlas texels (R8Unorm) and build the bind group. + pub fn upload( + device: &wgpu::Device, + queue: &wgpu::Queue, + atlas: &GlyphAtlas, + bgl1: &wgpu::BindGroupLayout, + ) -> Self { + let (w, h) = (atlas.width(), atlas.height()); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("text.atlas.texture"), + size: wgpu::Extent3d { + width: w, + height: h, + 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: &[], + }); + queue.write_texture( + texture.as_image_copy(), + atlas.data(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w), // R8: 1 byte per texel + rows_per_image: Some(h), + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("text.atlas.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, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("text.atlas.bind_group"), + layout: bgl1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + Self { + bind_group, + _texture: texture, + _sampler: sampler, + } + } +} + +// ── Pipeline ───────────────────────────────────────────────────────────────── + +/// Build the text render pipeline. Mirrors the image pipeline: alpha blending, +/// `LessEqual` depth with write, 4x MSAA. `frame_bgl` is group 0 (shared +/// uniforms), `atlas_bgl` is group 1 (the atlas). +pub fn create_pipeline( + device: &wgpu::Device, + frame_bgl: &wgpu::BindGroupLayout, + atlas_bgl: &wgpu::BindGroupLayout, + color_format: wgpu::TextureFormat, + sample_count: u32, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("text.wgsl"), + source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/text.wgsl").into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("text.pipeline.layout"), + bind_group_layouts: &[frame_bgl, atlas_bgl], + push_constant_ranges: &[], + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("text.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[TextVertex::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }) +} + +/// Upload a finished vertex list; `None` if empty. +pub fn upload_vertices(device: &wgpu::Device, verts: &[TextVertex]) -> Option { + if verts.is_empty() { + return None; + } + Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("text.vbuf"), + contents: bytemuck::cast_slice(verts), + usage: wgpu::BufferUsages::VERTEX, + })) +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn recon(v: &TextVertex) -> [f64; 3] { + [ + v.pos[0] as f64 + v.pos_low[0] as f64, + v.pos[1] as f64 + v.pos_low[1] as f64, + v.pos[2] as f64 + v.pos_low[2] as f64, + ] + } + + #[test] + fn quad_places_corners_and_uv() { + let q = GlyphQuad { + corners: [[0.0, 0.0], [8.0, 0.0], [8.0, 9.0], [0.0, 9.0]], + uv_min: [0.10, 0.20], + uv_max: [0.30, 0.40], + }; + let mut out = Vec::new(); + // Large origin exercises the double-single precision path. + push_glyph_vertices(&mut out, &[q], [1_000_000.0, 2_000_000.0, 0.0], 1.0, [1.0; 4], 0.0); + assert_eq!(out.len(), 6, "two triangles per glyph"); + + // Vertex 0 = BL corner (0,0) -> world origin, uv = (u_min, v_max). + let bl = recon(&out[0]); + assert!((bl[0] - 1_000_000.0).abs() < 1e-2 && (bl[1] - 2_000_000.0).abs() < 1e-2); + assert_eq!(out[0].uv, [0.10, 0.40]); + + // Vertex 2 = TR corner (8,9) -> origin + (8,9), uv = (u_max, v_min). + let tr = recon(&out[2]); + assert!((tr[0] - 1_000_008.0).abs() < 1e-2 && (tr[1] - 2_000_009.0).abs() < 1e-2); + assert_eq!(out[2].uv, [0.30, 0.20]); + } + + #[test] + fn annotation_scale_scales_corner_offsets() { + let q = GlyphQuad { + corners: [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], + uv_min: [0.0, 0.0], + uv_max: [1.0, 1.0], + }; + let mut out = Vec::new(); + push_glyph_vertices(&mut out, &[q], [0.0, 0.0, 0.0], 2.0, [1.0; 4], 0.0); + // TR corner (10,10) at anno 2.0 -> (20, 20). + let tr = recon(&out[2]); + assert!((tr[0] - 20.0).abs() < 1e-4 && (tr[1] - 20.0).abs() < 1e-4); + } + + #[test] + fn empty_run_yields_no_vertices() { + let mut out = Vec::new(); + push_glyph_vertices(&mut out, &[], [0.0, 0.0, 0.0], 1.0, [1.0; 4], 0.0); + assert!(out.is_empty()); + } +} diff --git a/src/scene/text/glyph_quads.rs b/src/scene/text/glyph_quads.rs new file mode 100644 index 000000000..dc7ca667e --- /dev/null +++ b/src/scene/text/glyph_quads.rs @@ -0,0 +1,199 @@ +//! Phase 2a of the text-shader initiative: lay a text run out as per-glyph +//! quads over the SDF atlas, instead of tessellating glyph outlines into wire +//! strokes. +//! +//! Each visible glyph becomes one quad whose corners are the glyph's atlas +//! `plane` rectangle (ink bbox + SDF spread, in 9-unit glyph space) run through +//! the exact same run transform the stroke path uses (`tessellate_text_run`): +//! scale = height/9, width factor, oblique skew, rotation. Corners come out in +//! run-local 2-D (origin at `[0, 0]`) — the same space `TextStroke.strokes` +//! use — so the downstream path (annotation scale, f64 origin, double-single +//! split) handles them identically. The paired atlas UV rect lets the fragment +//! shader sample the glyph's SDF tile. +//! +//! Layout here is per-character advance (LFF exact; TTF without shaping). TTF +//! shaping (kerning/ligatures via `shape_run`) is a later refinement — it needs +//! per-glyph offsets within a shaped run, which this cut does not yet apply. + +// Not yet wired into the render path (Phase 2b: the text quad pipeline). +#![allow(dead_code)] + +use crate::scene::text::font_face::Face; +use crate::scene::text::sdf_atlas::GlyphAtlas; + +/// One glyph placed as a quad in run-local 2-D space (origin at `[0, 0]`). +#[derive(Clone, Copy, Debug)] +pub struct GlyphQuad { + /// Corners, CCW: bottom-left, bottom-right, top-right, top-left. + pub corners: [[f32; 2]; 4], + /// Atlas UV of the tile (top-left / bottom-right). + pub uv_min: [f32; 2], + pub uv_max: [f32; 2], +} + +/// Lay `text` out as per-glyph quads. Mirrors `tessellate_text_run`'s transform +/// and per-character advance so the quads land exactly where the strokes would. +/// Whitespace and glyphs with no ink advance the pen but emit no quad. +#[allow(clippy::too_many_arguments)] +pub fn layout_glyph_quads( + atlas: &mut GlyphAtlas, + height: f32, + rotation: f32, + width_factor: f32, + oblique_angle: f32, + tracking: f32, + font_name: &str, + text: &str, +) -> Vec { + if text.is_empty() || height <= 0.0 { + return vec![]; + } + + let scale = height / 9.0; + let wf = if width_factor < 0.0 { + width_factor.clamp(-100.0, -0.01) + } else { + width_factor.clamp(0.01, 100.0) + }; + let ob = oblique_angle.tan(); + let (cos_r, sin_r) = (rotation.cos(), rotation.sin()); + + // Glyph-space (gx, gy) at pen position cx -> run-local 2-D (origin [0, 0]). + // Identical to `tessellate_text_run::xform` with `origin = [0, 0]`. + let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] { + let sx = (cx + gx) * scale * wf + gy * scale * ob; + let sy = gy * scale; + [sx * cos_r - sy * sin_r, sx * sin_r + sy * cos_r] + }; + + let face = Face::resolve(font_name); + let mut cursor_x = 0.0f32; + let mut quads = Vec::new(); + + for ch in text.chars() { + if ch == ' ' { + cursor_x += face.word_spacing(); + continue; + } + match atlas.get_or_insert(font_name, ch) { + Some(e) => { + let (lo, hi) = (e.plane_min, e.plane_max); + quads.push(GlyphQuad { + corners: [ + xform(lo[0], lo[1], cursor_x), + xform(hi[0], lo[1], cursor_x), + xform(hi[0], hi[1], cursor_x), + xform(lo[0], hi[1], cursor_x), + ], + uv_min: e.uv_min, + uv_max: e.uv_max, + }); + cursor_x += e.advance + face.letter_spacing() * tracking; + } + None => { + // No ink (whitespace glyph) or atlas full: advance only, using + // the glyph's own advance when the font knows it. + let adv = face.glyph(ch).map(|g| g.advance).unwrap_or(6.0); + cursor_x += adv + face.letter_spacing() * tracking; + } + } + } + + quads +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn center(q: &GlyphQuad) -> [f32; 2] { + let mut c = [0.0f32; 2]; + for p in &q.corners { + c[0] += p[0] * 0.25; + c[1] += p[1] * 0.25; + } + c + } + + // Uses the embedded "txt" stroke font (no system fonts needed). + #[test] + fn glyphs_advance_rightward() { + let mut atlas = GlyphAtlas::new(512, 512); + let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA"); + assert_eq!(quads.len(), 2, "two inked glyphs -> two quads"); + assert!( + center(&quads[1])[0] > center(&quads[0])[0], + "second glyph is to the right of the first" + ); + for q in &quads { + for p in &q.corners { + assert!(p[0].is_finite() && p[1].is_finite()); + } + assert!(q.uv_min[0] >= 0.0 && q.uv_max[0] <= 1.0); + assert!(q.uv_min[1] >= 0.0 && q.uv_max[1] <= 1.0); + } + } + + #[test] + fn space_emits_no_quad_but_advances() { + let mut atlas = GlyphAtlas::new(512, 512); + let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA"); + let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A A"); + assert_eq!(a_sp_a.len(), 2, "space produces no quad"); + assert!( + center(&a_sp_a[1])[0] > center(&ab[1])[0], + "the space widens the gap to the second glyph" + ); + } + + #[test] + fn rotation_maps_x_advance_to_y() { + let mut atlas = GlyphAtlas::new(512, 512); + // Use the baseline advance direction (glyph0 -> glyph1), which is +x when + // flat and swings to +y after a 90° turn — independent of where a single + // glyph's box centre sits within the cap height. + let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA"); + let turned = layout_glyph_quads( + &mut atlas, + 10.0, + std::f32::consts::FRAC_PI_2, + 1.0, + 0.0, + 1.0, + "txt", + "AA", + ); + let adv = |qs: &[GlyphQuad]| { + let (a, b) = (center(&qs[0]), center(&qs[1])); + [b[0] - a[0], b[1] - a[1]] + }; + let df = adv(&flat); + let dt = adv(&turned); + assert!(df[0] > df[1].abs(), "unrotated advance is along +x"); + assert!(dt[1] > dt[0].abs(), "90° advance swings to +y"); + } + + #[test] + fn taller_text_makes_bigger_quads() { + let mut atlas = GlyphAtlas::new(512, 512); + let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A"); + let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", "A"); + let extent = |q: &GlyphQuad| { + let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); + for p in &q.corners { + lo[0] = lo[0].min(p[0]); + lo[1] = lo[1].min(p[1]); + hi[0] = hi[0].max(p[0]); + hi[1] = hi[1].max(p[1]); + } + (hi[1] - lo[1]).max(hi[0] - lo[0]) + }; + let r = extent(&big[0]) / extent(&small[0]); + assert!( + (r - 2.0).abs() < 0.15, + "double height ~ double quad extent, got {r}" + ); + } +} diff --git a/src/scene/text/mod.rs b/src/scene/text/mod.rs index 7ae9dfe49..c2c1d3145 100644 --- a/src/scene/text/mod.rs +++ b/src/scene/text/mod.rs @@ -1,5 +1,7 @@ pub mod lff; pub mod font_face; +pub mod glyph_quads; +pub mod sdf_atlas; pub mod sysfont; pub mod ttf_glyph; pub mod web_font; diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs new file mode 100644 index 000000000..4fd7921ed --- /dev/null +++ b/src/scene/text/sdf_atlas.rs @@ -0,0 +1,434 @@ +//! CPU-side signed-distance-field (SDF) glyph atlas. +//! +//! Phase 1 of the text-shader initiative: bake each glyph into a single-channel +//! SDF tile and pack the tiles into one growable atlas texture. Later phases +//! draw text as per-glyph quads that sample this atlas in an SDF fragment +//! shader, instead of tessellating glyph outlines into wire segments. +//! +//! Both glyph kinds feed one unified field so a single shader renders both: +//! * Filled (TrueType): the glyph carries closed contours (`strokes`) plus a +//! fill triangulation (`fill_tris`). The field is the **signed** distance to +//! the outline — positive inside the fill, negative outside. +//! * Stroke (LFF pen fonts): the glyph carries open polylines and no fill. The +//! field is a **band** around the strokes: `pen_half - dist_to_polyline`, +//! positive inside the nominal pen width. +//! +//! In both cases the boundary sits at field value 0, mapped to 0.5 in the u8 +//! texel so the shader can threshold with `smoothstep(0.5 ± aa, value)`. +//! +//! Uses only crates already in the tree (no image/SDF crate): the distance +//! field is computed by brute-force point-to-segment distance over the glyph's +//! own polylines. Tiles are small and baked once per (font, char), then cached. + +// Phase 1 lands the CPU atlas builder; it is exercised by tests but not yet +// wired into the render path — that is Phase 2 (the text quad pipeline). Drop +// this allow once `GlyphAtlas` is consumed by the renderer. +#![allow(dead_code)] + +use std::collections::HashMap; + +use crate::scene::text::font_face::Face; +use crate::scene::text::lff::Glyph; + +// ── Bake configuration ────────────────────────────────────────────────────── + +/// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap +/// letter is ~9 * PX_PER_UNIT texels tall. SDF is resolution-independent when +/// sampled, so this only sets the baked field's fidelity, not on-screen size. +const PX_PER_UNIT: f32 = 6.0; + +/// Half-range of the distance field, in glyph units. Field values are clamped +/// to ±SPREAD_UNITS and linearly mapped to [0, 1] (0 -> 0.5). Also the tile +/// margin: the tile extends this far past the ink so the field/AA is not +/// clipped at the glyph edge. +const SPREAD_UNITS: f32 = 1.5; + +/// LFF stroke half-width, in glyph units. Stroke fonts have no intrinsic width +/// (plotted at the current lineweight); this is the nominal pen the SDF band is +/// built around. Scales with text height like the strokes themselves. +const PEN_HALF_UNITS: f32 = 0.35; + +/// Upper bound on a single glyph tile's dimension (texels), a guard against a +/// pathologically wide/tall glyph blowing up the atlas. +const MAX_TILE_PX: u32 = 512; + +// ── Public types ───────────────────────────────────────────────────────────── + +/// Placement of one baked glyph within the atlas. +#[derive(Clone, Copy, Debug)] +pub struct AtlasEntry { + /// Atlas UV of the tile's top-left corner (normalized 0..1). + pub uv_min: [f32; 2], + /// Atlas UV of the tile's bottom-right corner (normalized 0..1). + pub uv_max: [f32; 2], + /// Glyph-space (9-unit) rectangle the tile covers — the ink bounding box + /// expanded by the SDF spread. The render quad must span exactly this rect + /// (in glyph-local coords) so texels line up with world positions. + pub plane_min: [f32; 2], + pub plane_max: [f32; 2], + /// Glyph advance width (9-unit space). + pub advance: f32, +} + +/// A single-channel SDF glyph atlas plus a shelf packer. +pub struct GlyphAtlas { + width: u32, + height: u32, + /// R8 SDF texels, row-major, row 0 = top. + data: Vec, + /// Cache keyed by (font family, char). `None` = a whitespace/empty glyph + /// with no ink (nothing to draw), cached so we do not re-resolve it. + entries: HashMap<(String, char), Option>, + // Shelf packer cursor. + cursor_x: u32, + cursor_y: u32, + shelf_h: u32, + /// Set when `data` changed since the last upload; the GPU side clears it. + dirty: bool, +} + +/// A freshly baked, not-yet-packed glyph tile. +struct BakedTile { + w: u32, + h: u32, + data: Vec, + plane_min: [f32; 2], + plane_max: [f32; 2], + advance: f32, +} + +impl GlyphAtlas { + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + data: vec![0u8; (width * height) as usize], + entries: HashMap::new(), + cursor_x: 0, + cursor_y: 0, + shelf_h: 0, + dirty: false, + } + } + + pub fn width(&self) -> u32 { + self.width + } + pub fn height(&self) -> u32 { + self.height + } + /// Raw R8 texel data (row-major, row 0 = top) for GPU upload. + pub fn data(&self) -> &[u8] { + &self.data + } + pub fn is_dirty(&self) -> bool { + self.dirty + } + pub fn clear_dirty(&mut self) { + self.dirty = false; + } + + /// Look up a glyph's atlas placement, baking + packing it on first use. + /// Returns `None` for whitespace/empty glyphs (nothing to draw) and when the + /// atlas is full (Phase 1 is single-page; callers should treat `None` as + /// "skip / fall back"). + pub fn get_or_insert(&mut self, family: &str, ch: char) -> Option { + let key = (family.to_string(), ch); + if let Some(cached) = self.entries.get(&key) { + return *cached; + } + let face = Face::resolve(family); + let entry = face + .glyph(ch) + .and_then(|g| bake_glyph(&g)) + .and_then(|tile| self.pack(tile)); + self.entries.insert(key, entry); + entry + } + + /// Blit a baked tile into the atlas via a simple shelf packer. Returns the + /// placement, or `None` if the tile does not fit (atlas full). + fn pack(&mut self, tile: BakedTile) -> Option { + // Wrap to a new shelf if the tile overflows the current row. + if self.cursor_x + tile.w > self.width { + self.cursor_y += self.shelf_h; + self.cursor_x = 0; + self.shelf_h = 0; + } + if self.cursor_x + tile.w > self.width || self.cursor_y + tile.h > self.height { + return None; // atlas full + } + let (ox, oy) = (self.cursor_x, self.cursor_y); + for row in 0..tile.h { + let dst = ((oy + row) * self.width + ox) as usize; + let src = (row * tile.w) as usize; + self.data[dst..dst + tile.w as usize] + .copy_from_slice(&tile.data[src..src + tile.w as usize]); + } + self.cursor_x += tile.w; + self.shelf_h = self.shelf_h.max(tile.h); + self.dirty = true; + + let (fw, fh) = (self.width as f32, self.height as f32); + Some(AtlasEntry { + uv_min: [ox as f32 / fw, oy as f32 / fh], + uv_max: [(ox + tile.w) as f32 / fw, (oy + tile.h) as f32 / fh], + plane_min: tile.plane_min, + plane_max: tile.plane_max, + advance: tile.advance, + }) + } +} + +// ── Baking ───────────────────────────────────────────────────────────────── + +/// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink. +fn bake_glyph(g: &Glyph) -> Option { + let filled = !g.fill_tris.is_empty(); + + // Ink bounding box over all strokes (and fill triangles, for safety). + let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY); + let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY); + let mut acc = |x: f32, y: f32| { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + }; + for s in &g.strokes { + for &[x, y] in s { + acc(x, y); + } + } + for &[x, y] in &g.fill_tris { + acc(x, y); + } + if !min_x.is_finite() || max_x < min_x || max_y < min_y { + return None; // no geometry (whitespace) + } + + // Expand by the spread so the field is not clipped at the ink edge. + let plane_min = [min_x - SPREAD_UNITS, min_y - SPREAD_UNITS]; + let plane_max = [max_x + SPREAD_UNITS, max_y + SPREAD_UNITS]; + + let w = (((plane_max[0] - plane_min[0]) * PX_PER_UNIT).ceil() as u32) + .clamp(1, MAX_TILE_PX); + let h = (((plane_max[1] - plane_min[1]) * PX_PER_UNIT).ceil() as u32) + .clamp(1, MAX_TILE_PX); + + let mut data = vec![0u8; (w * h) as usize]; + let inv = 1.0 / PX_PER_UNIT; + for py in 0..h { + // Row 0 = top of the tile; glyph Y is up, so flip. + let gy = plane_max[1] - (py as f32 + 0.5) * inv; + for px in 0..w { + let gx = plane_min[0] + (px as f32 + 0.5) * inv; + let d = min_dist_to_strokes(gx, gy, &g.strokes); + let sd = if filled { + // Signed: positive inside the fill. + if point_in_fill(gx, gy, &g.fill_tris) { + d + } else { + -d + } + } else { + // Band around the pen path. + PEN_HALF_UNITS - d + }; + let t = (0.5 + sd / (2.0 * SPREAD_UNITS)).clamp(0.0, 1.0); + data[(py * w + px) as usize] = (t * 255.0).round() as u8; + } + } + + Some(BakedTile { + w, + h, + data, + plane_min, + plane_max, + advance: g.advance, + }) +} + +/// Minimum distance from `(x, y)` to any segment of any polyline. Treats a +/// single-point polyline as a point. Returns `+inf` if there is no geometry. +fn min_dist_to_strokes(x: f32, y: f32, strokes: &[Vec<[f32; 2]>]) -> f32 { + let mut best = f32::INFINITY; + for s in strokes { + if s.len() == 1 { + best = best.min(dist(x, y, s[0][0], s[0][1])); + continue; + } + for w in s.windows(2) { + best = best.min(dist_point_seg(x, y, w[0], w[1])); + } + } + best +} + +/// True if `(x, y)` lies in any triangle of a flat triangle-vertex list +/// (3 vertices per triangle). +fn point_in_fill(x: f32, y: f32, tris: &[[f32; 2]]) -> bool { + for t in tris.chunks_exact(3) { + if point_in_tri(x, y, t[0], t[1], t[2]) { + return true; + } + } + false +} + +fn dist(px: f32, py: f32, ax: f32, ay: f32) -> f32 { + ((px - ax) * (px - ax) + (py - ay) * (py - ay)).sqrt() +} + +fn dist_point_seg(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 { + let (abx, aby) = (b[0] - a[0], b[1] - a[1]); + let (apx, apy) = (px - a[0], py - a[1]); + let ab2 = abx * abx + aby * aby; + let t = if ab2 <= 1e-12 { + 0.0 + } else { + ((apx * abx + apy * aby) / ab2).clamp(0.0, 1.0) + }; + let (cx, cy) = (a[0] + abx * t, a[1] + aby * t); + dist(px, py, cx, cy) +} + +fn point_in_tri(px: f32, py: f32, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool { + // Sign of the cross products; inside if all the same sign (allowing edges). + let d1 = edge_sign(px, py, a, b); + let d2 = edge_sign(px, py, b, c); + let d3 = edge_sign(px, py, c, a); + let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(has_neg && has_pos) +} + +fn edge_sign(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 { + (px - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (py - b[1]) +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Sample the SDF value at a glyph-space point of a baked tile (0..255). + fn sample(tile: &BakedTile, gx: f32, gy: f32) -> u8 { + let px = (((gx - tile.plane_min[0]) * PX_PER_UNIT) as i32) + .clamp(0, tile.w as i32 - 1) as u32; + let py = (((tile.plane_max[1] - gy) * PX_PER_UNIT) as i32) + .clamp(0, tile.h as i32 - 1) as u32; + tile.data[(py * tile.w + px) as usize] + } + + /// A filled 4x4 square (fill_tris + closed contour). + fn filled_square() -> Glyph { + let sq = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]]; + Glyph { + strokes: vec![sq.to_vec()], + advance: 4.0, + fill_tris: vec![ + [0.0, 0.0], + [4.0, 0.0], + [4.0, 4.0], + [0.0, 0.0], + [4.0, 4.0], + [0.0, 4.0], + ], + } + } + + /// A single diagonal stroke, no fill (LFF-style). + fn diagonal_stroke() -> Glyph { + Glyph { + strokes: vec![vec![[0.0, 0.0], [4.0, 4.0]]], + advance: 4.0, + fill_tris: vec![], + } + } + + #[test] + fn filled_glyph_inside_is_high_outside_is_low() { + let tile = bake_glyph(&filled_square()).expect("square bakes"); + // Deep inside the fill -> well above the 0.5 (128) edge. + assert!(sample(&tile, 2.0, 2.0) > 200, "center should read as inside"); + // Well outside the fill -> below the edge. + assert!( + sample(&tile, -1.0, -1.0) < 60, + "far corner should read as outside" + ); + } + + #[test] + fn stroke_glyph_on_line_is_high_off_line_is_low() { + let tile = bake_glyph(&diagonal_stroke()).expect("stroke bakes"); + // On the diagonal -> inside the pen band. + assert!(sample(&tile, 2.0, 2.0) > 128, "on-stroke should be inside band"); + // Off the diagonal by more than the pen half-width -> outside. + assert!( + sample(&tile, 3.0, 0.5) < 128, + "off-stroke should be outside band" + ); + } + + #[test] + fn empty_glyph_has_no_tile() { + let space = Glyph { + strokes: vec![], + advance: 4.5, + fill_tris: vec![], + }; + assert!(bake_glyph(&space).is_none()); + } + + #[test] + fn packing_places_tiles_without_overlap() { + let mut atlas = GlyphAtlas::new(256, 256); + let a = atlas.pack(bake_glyph(&filled_square()).unwrap()).unwrap(); + let b = atlas.pack(bake_glyph(&diagonal_stroke()).unwrap()).unwrap(); + assert!(atlas.is_dirty()); + // UVs are within the atlas. + for e in [&a, &b] { + assert!(e.uv_min[0] >= 0.0 && e.uv_max[0] <= 1.0); + assert!(e.uv_min[1] >= 0.0 && e.uv_max[1] <= 1.0); + } + // Two tiles on the same shelf must not overlap in U. + let overlap_u = a.uv_min[0] < b.uv_max[0] && b.uv_min[0] < a.uv_max[0]; + let overlap_v = a.uv_min[1] < b.uv_max[1] && b.uv_min[1] < a.uv_max[1]; + assert!(!(overlap_u && overlap_v), "tiles overlap in the atlas"); + } + + #[test] + fn full_atlas_returns_none() { + // A tiny atlas cannot fit even one padded tile. + let mut atlas = GlyphAtlas::new(4, 4); + assert!(atlas.pack(bake_glyph(&filled_square()).unwrap()).is_none()); + } + + // Full path over a real, embedded stroke font (no system fonts needed): + // Face::resolve -> glyph -> bake -> pack, plus the (font, char) cache. + #[test] + fn real_lff_glyph_bakes_and_packs() { + let mut atlas = GlyphAtlas::new(512, 512); + let e = atlas.get_or_insert("txt", 'A').expect("LFF 'A' bakes"); + assert!(atlas.is_dirty()); + assert!( + e.plane_max[0] > e.plane_min[0] && e.plane_max[1] > e.plane_min[1], + "tile covers a positive-area glyph rect" + ); + assert!( + e.uv_max[0] > e.uv_min[0] + && e.uv_max[1] > e.uv_min[1] + && e.uv_max[0] <= 1.0 + && e.uv_max[1] <= 1.0, + "uv rect non-degenerate and within the atlas" + ); + assert!(e.advance > 0.0); + // Second lookup is cached: identical placement, no re-pack. + let e2 = atlas.get_or_insert("txt", 'A').expect("cached"); + assert_eq!(e.uv_min, e2.uv_min); + assert_eq!(e.uv_max, e2.uv_max); + } +} diff --git a/src/shaders/text.wgsl b/src/shaders/text.wgsl new file mode 100644 index 000000000..9f6577cdf --- /dev/null +++ b/src/shaders/text.wgsl @@ -0,0 +1,71 @@ +// SDF text-quad shader. One quad per glyph, sampling the single-channel SDF +// glyph atlas (built by scene::text::sdf_atlas). The atlas stores the glyph +// edge at value 0.5; screen-space derivatives antialias it at any zoom, so no +// per-size glyph baking or text LOD is needed. + +// ── Bind group 0: shared projection uniforms ───────────────────────────────── +// Must match the shared `Uniforms` struct (scene::pipeline::uniforms, 112 B). +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + _pad: vec2, + // Relative-to-eye (double-single): see wire.wgsl. + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +}; + +@group(0) @binding(0) var u: Uniforms; + +// ── Bind group 1: the shared glyph atlas ───────────────────────────────────── +@group(1) @binding(0) var atlas_tex: texture_2d; +@group(1) @binding(1) var atlas_samp: sampler; + +// Draw-order depth bias (see wire.wgsl / image.wgsl). +const DRAW_ORDER_BIAS: f32 = 0.001; + +// ── Vertex stage ────────────────────────────────────────────────────────────── +struct VertIn { + @location(0) pos: vec3, + @location(1) pos_low: vec3, + @location(2) uv: vec2, + @location(3) color: vec4, + @location(4) draw_depth: f32, +}; + +struct VertOut { + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, +}; + +@vertex +fn vs_main(in: VertIn) -> VertOut { + var out: VertOut; + // Double-single relative-to-eye, then rotation-only projection. + let rel = (in.pos - u.eye_high) + (in.pos_low - u.eye_low); + out.clip_pos = u.view_rot * vec4(rel, 1.0); + out.clip_pos.z = out.clip_pos.z - in.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; + out.uv = in.uv; + out.color = in.color; + return out; +} + +// ── Fragment stage ──────────────────────────────────────────────────────────── +@fragment +fn fs_main(in: VertOut) -> @location(0) vec4 { + // SDF value: 0.5 at the glyph edge, higher inside the ink. + let sd = textureSample(atlas_tex, atlas_samp, in.uv).r; + // Antialias over one screen-space texel of field change. + let aa = max(fwidth(sd), 1e-4); + let a = smoothstep(0.5 - aa, 0.5 + aa, sd); + if (a <= 0.0) { + discard; + } + return vec4(in.color.rgb, in.color.a * a); +}