Build the screen's model-space display list on all cores; read-write locks for glyph caches (#654)

Squash of PR #654 by @aldozar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aldozar
2026-10-12 03:21:04 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 935cb98eed
commit cf797cbafe
6 changed files with 146 additions and 17 deletions
+4
View File
@@ -16,5 +16,9 @@ cadcraft-fonts = { workspace = true }
tiny-skia = { workspace = true }
lyon_tessellation = { workspace = true }
# Display lists of large drawings are built on all cores (not on the web, which has no threads).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+74 -4
View File
@@ -250,6 +250,8 @@ struct Builder<'a> {
styles: Option<&'a plotstyle::Styler>,
/// Named plot style (table index) of the entity being drawn.
named: Option<usize>,
/// The block-expansion budget shared by the parts of a list built in parallel.
shared_budget: Option<&'a std::sync::atomic::AtomicUsize>,
}
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
@@ -368,7 +370,10 @@ impl Builder<'_> {
/// Take one unit of the block-expansion budget; false once it is spent.
fn expand(&mut self) -> bool {
self.list.expanded = self.list.expanded.saturating_add(1);
self.list.expanded <= MAX_BLOCK_EXPANSION
match self.shared_budget {
Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION,
None => self.list.expanded <= MAX_BLOCK_EXPANSION,
}
}
}
@@ -411,12 +416,17 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
_ => None,
});
let styler = table.map(|t| plotstyle::Styler::new(d, t));
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None };
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None };
if let Space::Paper(name) = space {
b.list.sheet = paper::sheet(d, name);
}
if let Some(store) = d.space(space) {
let paper = matches!(space, Space::Paper(_));
#[cfg(not(target_arch = "wasm32"))]
if !paper && !plotting && b.styles.is_none() && store.len() >= 2 * PARALLEL_CHUNK {
build_parallel(&mut b, d, store);
return b.list;
}
let mut viewports = 0usize;
for e in store.iter() {
if paper && let EntityKind::Viewport(vp) = &e.kind {
@@ -439,6 +449,66 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
b.list
}
/// Top-level entities per task when a display list is built on all cores.
#[cfg(not(target_arch = "wasm32"))]
const PARALLEL_CHUNK: usize = 256;
/// Build model space for the screen on all cores: the entities are split into chunks built
/// side by side, then joined in drawing order, so the list is the same as a one-thread build.
/// The canvas rebuilds the whole list at every 2x zoom step, on the UI thread; one core made
/// that a visible stall on real drawings. Plots, layouts and plot styles keep the one-thread
/// path (they run once, and their pens and per-primitive layers are built along the way).
#[cfg(not(target_arch = "wasm32"))]
fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) {
use rayon::prelude::*;
let ents: Vec<&std::sync::Arc<Entity>> = store.iter().collect();
let budget = std::sync::atomic::AtomicUsize::new(0);
let (opts, plotting) = (b.opts, b.plotting);
let parts: Vec<DisplayList> = ents
.par_chunks(PARALLEL_CHUNK)
.map(|chunk| {
let mut part = Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget) };
for e in chunk {
entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
part.list
})
.collect();
// One allocation per array for the joined list.
b.list.prims.reserve(parts.iter().map(|p| p.prims.len()).sum());
b.list.verts.reserve(parts.iter().map(|p| p.verts.len()).sum());
b.list.tris.reserve(parts.iter().map(|p| p.tris.len()).sum());
for part in parts {
append(&mut b.list, part);
}
}
/// Append `src` to `dst`: its primitives index into `dst`'s vertex and triangle arrays.
#[cfg(not(target_arch = "wasm32"))]
fn append(dst: &mut DisplayList, src: DisplayList) {
let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32);
// Pens are parallel to the primitives, with missing entries meaning "use object".
if !src.pens.is_empty() {
dst.pens.resize(dst.prims.len(), Pen::default());
}
dst.prims.extend(src.prims.into_iter().map(|mut p| {
p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts };
p
}));
dst.verts.extend(src.verts);
dst.tris.extend(src.tris);
dst.prim_layers.extend(src.prim_layers);
dst.texts.extend(src.texts.into_iter().map(|mut t| {
if t.prim < PlotText::CLIPPED {
t.prim += prims;
}
t
}));
dst.pens.extend(src.pens);
dst.bounds = dst.bounds.union(&src.bounds);
dst.expanded = dst.expanded.saturating_add(src.expanded);
}
/// `opts` with a view height for relative point sizes: the extents height when none is given
/// and points draw as figures sized relative to the view.
fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options {
@@ -493,7 +563,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
let mcenter = vp.view_center + (rect.center() - center) / s;
// Model window with slack: entity bounds are approximate (text, dimensions).
let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None };
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None };
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
// paper space, whatever the viewport scale.
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
@@ -631,7 +701,7 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
} else {
opts
};
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None };
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None };
for e in ents {
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
+7 -6
View File
@@ -1,7 +1,7 @@
//! Filled font glyphs: bounded font-unit meshes shared across placements and zoom rebuilds.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
use cadcraft_fonts::{TextGlyph, ttf};
use cadcraft_geom::{Bounds2, Mat3, Vec2};
@@ -43,9 +43,10 @@ struct Cache {
bytes: usize,
}
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(Cache::default()))
/// A read-write lock: display lists are built on all cores and nearly every lookup is a hit.
fn cache() -> &'static RwLock<Cache> {
static CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();
CACHE.get_or_init(|| RwLock::new(Cache::default()))
}
/// World-space placement of font units, including the block/viewport transform.
@@ -70,7 +71,7 @@ fn detail(xf: Mat3, pixel: f64) -> Detail {
pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc<Mesh> {
let detail = detail(xf, pixel);
let key = (g.face.bytes.as_ptr() as usize, g.face.bytes.len(), g.face.index, g.gid, detail);
if let Some(entry) = cache().lock().unwrap_or_else(PoisonError::into_inner).entries.get(&key) {
if let Some(entry) = cache().read().unwrap_or_else(PoisonError::into_inner).entries.get(&key) {
return entry.mesh.clone();
}
let contours = ttf::glyph_contours(&g.face, g.gid);
@@ -98,7 +99,7 @@ pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc<Mesh> {
.saturating_add(mesh.contours.len().saturating_mul(std::mem::size_of::<Vec<Vec2>>()))
.saturating_add(std::mem::size_of::<Entry>() + std::mem::size_of::<Key>() + std::mem::size_of::<Mesh>());
if bytes <= MAX_CACHE_BYTES {
let mut c = cache().lock().unwrap_or_else(PoisonError::into_inner);
let mut c = cache().write().unwrap_or_else(PoisonError::into_inner);
if c.entries.len() >= MAX_ENTRIES || c.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
c.entries.clear();
c.bytes = 0;