feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag

Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.

- text::text_run_placement — extracted from to_truck (behaviour-preserved)
  so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
  by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
  TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
  OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
  (rebuilds the R8 atlas texture when it grows), and a text render pass
  drawn over the wires.

Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-06 23:02:34 +03:00
commit 896b6c385c
5 changed files with 208 additions and 20 deletions

View file

@ -47,7 +47,55 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) {
}
}
/// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy)
/// plus every parameter needed to lay the glyphs out. Shared by `to_truck` (the
/// stroke path) and the SDF-quad text collector so both place text identically.
pub struct TextPlacement {
/// Run-local origin (glyph space `[0,0]` maps here), WCS xy, kept f64.
pub origin: [f64; 2],
/// Entity elevation (WCS z).
pub elevation: f64,
pub height: f32,
pub rotation: f32,
pub width_factor: f32,
pub oblique_angle: f32,
pub font: String,
/// Raw entity value (as passed to the tessellator).
pub value: String,
/// Full WCS insertion point, for the Insertion snap.
pub wcs_insertion: [f64; 3],
}
fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
let p = text_run_placement(t, document);
let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]);
// Strokes are in glyph-local space (origin = [0,0]).
let (strokes, fill_tris) = lff::tessellate_text_ex(
[0.0, 0.0],
p.height,
p.rotation,
p.width_factor,
p.oblique_angle,
&p.font,
&p.value,
);
TruckEntity {
object: TruckObject::Text(vec![TextStroke {
strokes,
origin: p.origin,
color: None,
fill_tris,
}]),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
}
}
/// Compute a TEXT entity's run placement (origin + layout params). Extracted
/// from `to_truck` verbatim so the stroke and SDF-quad paths agree exactly.
pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement {
let normal = (t.normal.x, t.normal.y, t.normal.z);
let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs(
(
@ -57,7 +105,6 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
),
normal,
);
let snap_pt = glam::DVec3::new(wsx, wsy, wsz);
let resolved_style = resolve_text_style(&t.style, document);
let font_name = resolved_style.font_name;
// AutoCAD text geometry rule: the entity stores the FINAL width factor /
@ -142,27 +189,16 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
anchor_f64[0] - (anchor_local_x as f64 * cos_r - anchor_local_y as f64 * sin_r),
anchor_f64[1] - (anchor_local_x as f64 * sin_r + anchor_local_y as f64 * cos_r),
];
// Strokes are in glyph-local space (origin = [0,0]).
let (strokes, fill_tris) = lff::tessellate_text_ex(
[0.0, 0.0],
t.height as f32,
TextPlacement {
origin,
elevation: wsz,
height: t.height as f32,
rotation,
width_factor,
oblique_angle,
&font_name,
&t.value,
);
TruckEntity {
object: TruckObject::Text(vec![TextStroke {
strokes,
origin,
color: None,
fill_tris,
}]),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
font: font_name,
value: t.value.clone(),
wcs_insertion: [wsx, wsy, wsz],
}
}

View file

@ -48,6 +48,9 @@ pub struct Pipeline {
/// vertex-stage storage buffers.
hatch_batched_pipeline: Option<wgpu::RenderPipeline>,
image_pipeline: wgpu::RenderPipeline,
/// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the
/// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts).
text_pipeline: wgpu::RenderPipeline,
mesh_pipeline: wgpu::RenderPipeline,
/// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids.
mesh_transparent_pipeline: wgpu::RenderPipeline,
@ -74,6 +77,14 @@ pub struct Pipeline {
/// for instances / boundary / families / dashes). `None` on WebGL2.
hatch_batched_bgl1: Option<wgpu::BindGroupLayout>,
image_bgl1: wgpu::BindGroupLayout,
/// Group-1 layout for the text pipeline (atlas texture + sampler).
text_atlas_bgl: wgpu::BindGroupLayout,
/// GPU glyph atlas (texture + sampler + bind group). Rebuilt when the shared
/// CPU atlas grows (new glyphs baked). `None` until the first text upload.
text_atlas_gpu: Option<text_gpu::TextAtlasGpu>,
/// All glyph-quad vertices for the frame, one buffer, `None` when empty.
text_vbuf: Option<wgpu::Buffer>,
text_vcount: u32,
depth_texture_size: Size<u32>,
depth_view: wgpu::TextureView,
/// 4× MSAA color buffer for the main drawing passes.
@ -986,6 +997,11 @@ impl Pipeline {
cache: None,
});
// ── Text (SDF glyph quads) ─────────────────────────────────────────
let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device);
let text_pipeline =
text_gpu::create_pipeline(device, &frame_bgl, &text_atlas_bgl, format, MSAA_SAMPLES);
let viewcube = ViewCubePipeline::new(device, queue, format);
let init_size = Size::new(1, 1);
@ -1125,6 +1141,11 @@ impl Pipeline {
hatch_pipeline,
hatch_batched_pipeline,
image_pipeline,
text_pipeline,
text_atlas_bgl,
text_atlas_gpu: None,
text_vbuf: None,
text_vcount: 0,
mesh_pipeline,
mesh_transparent_pipeline,
mesh_highlight_pipeline,
@ -1528,6 +1549,30 @@ impl Pipeline {
.collect();
}
/// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph
/// atlas from the shared CPU atlas when it grew (new glyphs baked by the
/// text collector). `verts` empty (flag off) leaves nothing to draw.
pub fn upload_text(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
verts: &[text_gpu::TextVertex],
) {
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
if self.text_atlas_gpu.is_none() || atlas.is_dirty() {
self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload(
device,
queue,
&atlas,
&self.text_atlas_bgl,
));
atlas.clear_dirty();
}
}
self.text_vbuf = text_gpu::upload_vertices(device, verts);
self.text_vcount = verts.len() as u32;
}
pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) {
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
@ -2020,6 +2065,40 @@ impl Pipeline {
}
}
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
if let (Some(vbuf), Some(atlas)) = (&self.text_vbuf, &self.text_atlas_gpu) {
if self.text_vcount > 0 {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("text.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.text_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_bind_group(1, &atlas.bind_group, &[]);
pass.set_vertex_buffer(0, vbuf.slice(..));
pass.draw(0..self.text_vcount, 0..1);
}
}
// ── Pass 6: wipeout fills (drawn after wires to mask them) ────────
if !self.gpu_wipeouts.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {

View file

@ -22,7 +22,7 @@ use crate::scene::text::sdf_atlas::GlyphAtlas;
// ── Vertex ────────────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct TextVertex {
pub pos: [f32; 3],
pub pos_low: [f32; 3],

View file

@ -26,10 +26,19 @@
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::scene::text::font_face::Face;
use crate::scene::text::lff::Glyph;
/// Process-wide glyph atlas, shared by the text collector (which bakes glyphs
/// while building draw data) and the GPU upload (which reads the texels).
/// Mirrors the existing global font-glyph caches (see `ttf_glyph`).
pub fn text_atlas() -> &'static Mutex<GlyphAtlas> {
static ATLAS: OnceLock<Mutex<GlyphAtlas>> = OnceLock::new();
ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024)))
}
// ── Bake configuration ──────────────────────────────────────────────────────
/// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap

View file

@ -39,6 +39,8 @@ pub struct ViewportData {
/// 3DFACE entity wires — separated so they are uploaded to the dedicated
/// face3d pipeline (fill + batched edges) instead of N individual WireGpu.
pub(in crate::scene) face3d_wires: Arc<Vec<WireModel>>,
/// SDF text-quad vertices (Phase 2b). Empty unless `OCS_TEXT_SDF` is set.
pub(in crate::scene) text_verts: Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>,
/// Per-entity normalized draw-order depth (handle.value() → (0,1)), used
/// by the wire / face3d pipelines as a clip-z bias. WireModels carry no
/// depth field (84 construction sites); the bias is looked up by handle
@ -298,6 +300,7 @@ impl shader::Primitive for Primitive {
}
if geo_changed {
inner.upload_images(device, queue, &vp.images[..]);
inner.upload_text(device, queue, &vp.text_verts[..]);
}
inner.cached_epoch = cur_key;
}
@ -772,6 +775,59 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
// ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ──
impl Scene {
/// SDF text-quad vertices for the current document's TEXT entities. Behind
/// the `OCS_TEXT_SDF` env flag (Phase 2b bring-up) so the default render
/// path is untouched; empty otherwise. Glyphs are baked into the shared
/// atlas as a side effect. Both the stroke path and this share
/// `text_run_placement`, so quads land exactly where the strokes do.
#[allow(dead_code)] // consumed by the text render pass in the GPU integration step
pub(in crate::scene) fn sdf_text_vertices(
&self,
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
self.sdf_text_vertices_enabled(std::env::var_os("OCS_TEXT_SDF").is_some())
}
#[allow(dead_code)]
fn sdf_text_vertices_enabled(
&self,
enabled: bool,
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
use crate::scene::pipeline::text_gpu;
use crate::scene::text::{glyph_quads, sdf_atlas};
if !enabled {
return Vec::new();
}
let Ok(mut atlas) = sdf_atlas::text_atlas().lock() else {
return Vec::new();
};
let mut out = Vec::new();
for e in self.document.entities() {
if let acadrust::EntityType::Text(t) = e {
let p = crate::entities::text::text_run_placement(t, &self.document);
let color = self.render_style(e).0;
let quads = glyph_quads::layout_glyph_quads(
&mut atlas,
p.height,
p.rotation,
p.width_factor,
p.oblique_angle,
1.0,
&p.font,
&p.value,
);
text_gpu::push_glyph_vertices(
&mut out,
&quads,
[p.origin[0], p.origin[1], p.elevation],
1.0,
color,
0.0,
);
}
}
out
}
/// Build the unified multi-viewport `Primitive` for the current layout.
/// Model layout → one full-window viewport (more once tiled); paper
/// layout → one viewport per floating content viewport. Each entry is
@ -1053,10 +1109,18 @@ impl Scene {
self.meshes_arc()
};
// SDF text quads (Phase 2b, behind OCS_TEXT_SDF). Model/content only —
// the paper sheet must not draw model-space text onto the sheet.
let text_verts = if inst.paper_sheet {
Arc::new(Vec::new())
} else {
Arc::new(self.sdf_text_vertices())
};
Some(ViewportData {
wires: all_wires,
preview_wires,
face3d_wires,
text_verts,
draw_depths: self.draw_depth_map(),
hatches,
wipeout_hatches,