feat(text): SDF glyph-atlas foundation for shader text rendering

Groundwork to render CAD text as per-glyph SDF quads (box-quad snap,
perf, no LOD) instead of stroke-polyline wires. CPU side fully tested;
GPU side compiles but is not yet wired into the render loop.

- scene::text::sdf_atlas — GlyphAtlas: shelf packer + R8 SDF tiles baked
  per (font, char). Unified field so one shader draws both kinds: TrueType
  = signed distance to the filled outline, LFF stroke = pen band. Uses only
  crates already in the tree (ttf-parser/lyon via the existing Glyph).
- scene::text::glyph_quads — layout_glyph_quads: lays a run out as per-glyph
  quads using the exact tessellate_text_run transform, so quads land where
  the strokes would. Per-char advance (TTF shaping is a follow-up).
- shaders/text.wgsl — SDF fragment shader (smoothstep over fwidth), RTE
  vertex path mirroring image.wgsl, per-vertex colour + draw-order bias.
- scene::pipeline::text_gpu — TextVertex + double-single vertex builder,
  R8 atlas texture upload, bind group, and the image-cloned pipeline.

13 unit tests across the three CPU stages, all green. Modules carry
#![allow(dead_code)] until the render-loop integration lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-06 22:03:36 +03:00
1 parent 9490c85b32
commit 374cf7e89f
6 files changed
+1065

No files matched your search

+1
View File
@@ -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;
+358
View File
@@ -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::<TextVertex>() 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<TextVertex>,
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<wgpu::Buffer> {
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());
}
}
+199
View File
@@ -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<GlyphQuad> {
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}"
);
}
}
+2
View File
@@ -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;
+434
View File
@@ -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<u8>,
/// 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<AtlasEntry>>,
// 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<u8>,
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<AtlasEntry> {
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<AtlasEntry> {
// 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<BakedTile> {
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);
}
}
+71
View File
@@ -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<f32>,
world_per_pixel: f32,
lwdisplay_enable: f32,
flat_shade: f32,
transparency_enable: f32,
_pad: vec2<f32>,
// Relative-to-eye (double-single): see wire.wgsl.
view_rot: mat4x4<f32>,
eye_high: vec3<f32>,
_pad_eh: f32,
eye_low: vec3<f32>,
_pad_el: f32,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
// ── Bind group 1: the shared glyph atlas ─────────────────────────────────────
@group(1) @binding(0) var atlas_tex: texture_2d<f32>;
@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<f32>,
@location(1) pos_low: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
@location(4) draw_depth: f32,
};
struct VertOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@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<f32>(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<f32> {
// 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<f32>(in.color.rgb, in.color.a * a);
}