forked from 1b-agent/opencadstudio-upstream
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:
1 parent
9490c85b32
commit
374cf7e89f
6 files changed
+1065
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user