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:
parent
374cf7e89f
commit
896b6c385c
5 changed files with 208 additions and 20 deletions
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue