mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
M9: render perf (glyph path cache, culling) + soft drop shadow/feather/glow; perf harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6086ceb497
commit
b62a795602
18 files changed
+1666
-147
No files matched your search
@@ -32,3 +32,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
|
||||
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
|
||||
| `docs/images/ui-typing.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
|
||||
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
Generated
+5
@@ -843,9 +843,14 @@ dependencies = [
|
||||
name = "designcraft-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
"designcraft-geom",
|
||||
"designcraft-mcp",
|
||||
"designcraft-pdf",
|
||||
"designcraft-render",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -16,5 +16,10 @@ path = "src/main.rs"
|
||||
designcraft-engine = { workspace = true }
|
||||
designcraft-render = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-compose = { workspace = true }
|
||||
designcraft-format = { workspace = true }
|
||||
designcraft-pdf = { workspace = true }
|
||||
designcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -4,9 +4,13 @@
|
||||
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml] [--pdf-options JSON] [--all-pages DIR]
|
||||
//! designcraft-cli commands # list every command (JSON)
|
||||
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
|
||||
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
|
||||
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
|
||||
//! ```
|
||||
use std::process::ExitCode;
|
||||
|
||||
mod perf;
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -20,9 +24,11 @@ fn main() -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("mcp") => report(mcp(&args[1..])),
|
||||
Some("perf") => report(perf::perf(&args[1..])),
|
||||
Some("bench") => report(perf::bench(&args[1..])),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]"
|
||||
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]"
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
//! `designcraft-cli perf` / `bench`: the performance budgets (plan/architecture.md §9) measured on
|
||||
//! a synthetic stress document, or on a given file.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above ~¾ of the core count) they are noise, and the report says so.
|
||||
//!
|
||||
//! Zoom levels follow the canvas: 100% on a 2880×1800 HiDPI canvas is 2 device pixels per point.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, StoryId};
|
||||
use designcraft_geom::{Affine, Rect, Vec2, shapes};
|
||||
use designcraft_render::{Placed, RenderOptions, Renderer};
|
||||
|
||||
/// Device pixels per point at 100% (HiDPI canvas).
|
||||
const HIDPI: f64 = 2.0;
|
||||
const W: u32 = 2880;
|
||||
const H: u32 = 1800;
|
||||
|
||||
/// Deterministic xorshift in 0..1.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 % 1_000_000) as f64 / 1_000_000.0
|
||||
}
|
||||
fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str {
|
||||
v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
const WORDS: &[&str] = &[
|
||||
"the",
|
||||
"grid",
|
||||
"page",
|
||||
"margin",
|
||||
"column",
|
||||
"rhythm",
|
||||
"baseline",
|
||||
"reader",
|
||||
"story",
|
||||
"frame",
|
||||
"quiet",
|
||||
"layout",
|
||||
"type",
|
||||
"paragraph",
|
||||
"composer",
|
||||
"weighs",
|
||||
"every",
|
||||
"line",
|
||||
"break",
|
||||
"texture",
|
||||
"even",
|
||||
"hyphen",
|
||||
"image",
|
||||
"spread",
|
||||
"pulse",
|
||||
"caption",
|
||||
"colour",
|
||||
"swatch",
|
||||
"ink",
|
||||
"tint",
|
||||
"gradient",
|
||||
"style",
|
||||
"voice",
|
||||
"printer",
|
||||
"proof",
|
||||
"bleed",
|
||||
"a",
|
||||
"of",
|
||||
"and",
|
||||
"to",
|
||||
"in",
|
||||
"with",
|
||||
"for",
|
||||
"is",
|
||||
"on",
|
||||
"that",
|
||||
"by",
|
||||
"it",
|
||||
"as",
|
||||
"careful",
|
||||
"measure",
|
||||
"leading",
|
||||
"kerning",
|
||||
"tracking",
|
||||
"glyph",
|
||||
"serif",
|
||||
"italic",
|
||||
"heading",
|
||||
"running",
|
||||
"folio",
|
||||
];
|
||||
|
||||
/// `chars` characters of original filler text in paragraphs of ~400 characters.
|
||||
fn filler(r: &mut Rng, chars: usize) -> String {
|
||||
let mut s = String::with_capacity(chars + 16);
|
||||
let mut para = 0;
|
||||
while s.len() < chars {
|
||||
let w = r.pick(WORDS);
|
||||
if para == 0 {
|
||||
let mut c = w.chars();
|
||||
if let Some(f) = c.next() {
|
||||
s.extend(f.to_uppercase());
|
||||
s.push_str(c.as_str());
|
||||
}
|
||||
} else {
|
||||
s.push_str(w);
|
||||
}
|
||||
para += w.len() + 1;
|
||||
if para > 400 {
|
||||
s.push_str(".\n");
|
||||
para = 0;
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Procedural RGB image, PNG-encoded.
|
||||
fn art_png(w: u32, h: u32, seed: f32) -> Vec<u8> {
|
||||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0);
|
||||
px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]);
|
||||
}
|
||||
}
|
||||
designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png()
|
||||
}
|
||||
|
||||
/// Parameters of the synthetic stress document.
|
||||
pub struct Spec {
|
||||
pub pages: usize,
|
||||
pub frames_per_page: usize,
|
||||
pub frames_per_story: usize,
|
||||
pub chars: usize,
|
||||
pub images: usize,
|
||||
pub assets: usize,
|
||||
}
|
||||
|
||||
impl Default for Spec {
|
||||
fn default() -> Self {
|
||||
Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text
|
||||
/// frames, and a strip of placed images (one with a drop shadow per spread).
|
||||
pub fn synthetic(spec: &Spec) -> Document {
|
||||
let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
d.swatches.push(Swatch {
|
||||
name: "Perf Gradient".into(),
|
||||
value: SwatchValue::Gradient {
|
||||
gradient: Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::cmyk(0.6, 0.9, 0.3, 0.2), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.6, 0.8, 0.0), opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
locked: false,
|
||||
named: true,
|
||||
});
|
||||
let mut rng = Rng(0x5eed);
|
||||
let (aw, ah) = (800u32, 600u32);
|
||||
let assets: Vec<AssetId> = (0..spec.assets.max(1))
|
||||
.map(|k| {
|
||||
let id = AssetId(d.alloc());
|
||||
let data = art_png(aw, ah, k as f32 * 0.37);
|
||||
d.assets.insert(
|
||||
id,
|
||||
Arc::new(Asset {
|
||||
id,
|
||||
name: format!("art-{k}.png"),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(data),
|
||||
pixels: Some((aw, ah)),
|
||||
}),
|
||||
);
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
let total_frames = spec.pages * spec.frames_per_page;
|
||||
let stories = total_frames.div_ceil(spec.frames_per_story.max(1));
|
||||
let per_story = spec.chars / stories.max(1);
|
||||
let rows = spec.frames_per_page.div_ceil(2);
|
||||
let (top, strip) = (48.0, 130.0);
|
||||
let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64;
|
||||
let mut prev: Option<ItemId> = None;
|
||||
let mut n_frame = 0usize;
|
||||
let mut placed_images = 0usize;
|
||||
for abs in 0..spec.pages {
|
||||
let Some((si, pi)) = d.page_loc(abs) else { break };
|
||||
let sr = SpreadRef::Doc(si);
|
||||
let x0 = d.spreads[si].pages[pi].x;
|
||||
// Gradient band.
|
||||
let id = ItemId(d.alloc());
|
||||
let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0)));
|
||||
band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() };
|
||||
band.fill.gradient_angle = Some(rng.next() * 90.0);
|
||||
d.insert_item(sr, band, None).expect("spread");
|
||||
// Text frames.
|
||||
for k in 0..spec.frames_per_page {
|
||||
let (c, r) = (k % 2, k / 2);
|
||||
let fx = x0 + 36.0 + c as f64 * 280.0;
|
||||
let fy = top + r as f64 * row_h;
|
||||
let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0);
|
||||
let first = n_frame.is_multiple_of(spec.frames_per_story.max(1));
|
||||
let text = if first { filler(&mut rng, per_story) } else { String::new() };
|
||||
let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).expect("frame");
|
||||
if let (false, Some(p)) = (first, prev) {
|
||||
d.thread(p, fid).expect("thread");
|
||||
}
|
||||
prev = Some(fid);
|
||||
n_frame += 1;
|
||||
}
|
||||
// Images: spread the requested count evenly over the pages.
|
||||
let want = (spec.images * (abs + 1)) / spec.pages - placed_images;
|
||||
for k in 0..want {
|
||||
let w = 540.0 / want as f64;
|
||||
let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0);
|
||||
let asset = assets[(placed_images + k) % assets.len()];
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
let (nw, nh) = (aw as f64, ah as f64);
|
||||
let s = (r.width() / nw).max(r.height() / nh);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
});
|
||||
if k == 0 && pi == 0 {
|
||||
it.effects.drop_shadow.on = true;
|
||||
}
|
||||
d.insert_item(sr, it, None).expect("spread");
|
||||
}
|
||||
placed_images += want;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
let mut v: Vec<f64> = (0..runs.max(1))
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
fn load_average() -> Option<f64> {
|
||||
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
|
||||
let text = match out {
|
||||
Some(o) => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
None => std::fs::read_to_string("/proc/loadavg").ok()?,
|
||||
};
|
||||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
/// Compose every story into a fresh cache on `threads` threads.
|
||||
fn compose_all(doc: &Document, threads: usize) -> Arc<Cache> {
|
||||
let cache = Arc::new(Cache::new());
|
||||
let ids: Vec<StoryId> = doc.stories.keys().copied().collect();
|
||||
let next = AtomicUsize::new(0);
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..threads.max(1) {
|
||||
s.spawn(|| {
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(id) = ids.get(i) else { break };
|
||||
drop(cache.get(doc, *id, None));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
cache
|
||||
}
|
||||
|
||||
/// All spreads stacked vertically, centred on x = 0 (like the canvas).
|
||||
fn layout(doc: &Document) -> Vec<(Placed, Rect)> {
|
||||
let mut y = 0.0;
|
||||
doc.spreads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sp)| {
|
||||
let b = sp.bounds();
|
||||
let offset = Vec2::new(-b.center().x, y - b.y0);
|
||||
y += b.height() + 36.0;
|
||||
(Placed { spread: SpreadRef::Doc(i), offset }, b + offset)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// View transform showing canvas point `c` at the centre of the image at `scale` px/pt.
|
||||
fn view_at(c: designcraft_geom::Point, scale: f64) -> Affine {
|
||||
Affine::translate((W as f64 / 2.0, H as f64 / 2.0)) * Affine::scale(scale) * Affine::translate(-c.to_vec2())
|
||||
}
|
||||
|
||||
struct Row {
|
||||
name: String,
|
||||
ms: f64,
|
||||
budget: Option<f64>,
|
||||
}
|
||||
|
||||
fn row(rows: &mut Vec<Row>, name: impl Into<String>, ms: f64, budget: Option<f64>) {
|
||||
let name = name.into();
|
||||
eprintln!(" … {name}: {ms:.2} ms");
|
||||
rows.push(Row { name, ms, budget });
|
||||
}
|
||||
|
||||
/// Measure `doc`. `runs` = repetitions per row (median).
|
||||
fn measure(doc: &Document, runs: usize) -> Vec<Row> {
|
||||
let mut rows = vec![];
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let chars: usize = doc.stories.values().map(|s| s.text.len()).sum();
|
||||
// Budget: 250 ms per 300k characters (MT), scaled to this document.
|
||||
let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0);
|
||||
let mut cache = Arc::new(Cache::new());
|
||||
row(
|
||||
&mut rows,
|
||||
format!("compose all stories cold, MT ({} stories, {}k chars)", doc.stories.len(), chars / 1000),
|
||||
median_ms(runs.min(3), || cache = compose_all(doc, cores)),
|
||||
Some(compose_budget),
|
||||
);
|
||||
row(&mut rows, "compose all stories cold, 1 thread", median_ms(1, || drop(compose_all(doc, 1))), None);
|
||||
|
||||
let placed = layout(doc);
|
||||
let all: Vec<Placed> = placed.iter().map(|p| p.0).collect();
|
||||
let mid = placed.len() / 2;
|
||||
let centre = placed[mid].1.center();
|
||||
let greek = |scale: f64| RenderOptions {
|
||||
background: Some([230, 230, 230, 255]),
|
||||
page_shadow: true,
|
||||
greek_below_px: 3.0 * HIDPI.min(scale.max(1.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut r = Renderer::new();
|
||||
// Warm-up: decode images, glyph outlines, compose (already cached).
|
||||
let fit = {
|
||||
let b = placed[mid].1;
|
||||
let s = ((W as f64 - 80.0) / b.width()).min((H as f64 - 80.0) / b.height());
|
||||
(s, view_at(centre, s))
|
||||
};
|
||||
let dump = std::env::var("DESIGNCRAFT_PERF_DUMP").ok();
|
||||
let save = |name: &str, img: designcraft_render::Rendered| {
|
||||
if let Some(d) = &dump {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
let _ = std::fs::write(format!("{d}/{name}.png"), img.to_png());
|
||||
}
|
||||
};
|
||||
save("fit", r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)));
|
||||
row(
|
||||
&mut rows,
|
||||
format!("render spread, fit ({:.0}%), {W}×{H}", fit.0 / HIDPI * 100.0),
|
||||
median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)))),
|
||||
Some(16.0),
|
||||
);
|
||||
for zoom in [0.25, 1.0, 4.0] {
|
||||
let s = zoom * HIDPI;
|
||||
// At 100% look at the text block of the left page.
|
||||
let c = if zoom >= 1.0 { centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0) } else { centre };
|
||||
let v = view_at(c, s);
|
||||
save(&format!("zoom-{:.0}", zoom * 100.0), r.render(doc, &cache, &all, W, H, v, &greek(s)));
|
||||
let budget = if zoom == 1.0 { Some(16.0) } else { None };
|
||||
row(
|
||||
&mut rows,
|
||||
format!("render at {:.0}%, {W}×{H}", zoom * 100.0),
|
||||
median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, v, &greek(s)))),
|
||||
budget,
|
||||
);
|
||||
}
|
||||
// Panning at 100%: a new view every frame (no pixel cache), same glyph caches.
|
||||
let mut dy = 0.0;
|
||||
let pan = median_ms(runs, || {
|
||||
dy += 37.0;
|
||||
let v = view_at(centre + Vec2::new(-200.0, dy - 300.0), HIDPI);
|
||||
drop(r.render(doc, &cache, &all, W, H, v, &greek(HIDPI)));
|
||||
});
|
||||
row(&mut rows, "render at 100% while panning", pan, Some(16.0));
|
||||
// Cold renderer (no glyph-path / image caches): first frame after open.
|
||||
let cold = median_ms(runs.min(3), || {
|
||||
let mut r = Renderer::new();
|
||||
drop(r.render(doc, &cache, &all, W, H, view_at(centre, HIDPI), &greek(HIDPI)));
|
||||
});
|
||||
row(&mut rows, "render at 100%, cold renderer", cold, None);
|
||||
|
||||
// Keystroke: insert a character in the story of the spread's first frame, recompose, repaint.
|
||||
if let Some(sid) = doc.spreads[mid].items.iter().find_map(|i| i.text_frame().map(|t| t.story)) {
|
||||
let mut d = doc.clone();
|
||||
let mut k = 0usize;
|
||||
let v = view_at(centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0), HIDPI);
|
||||
let ms = median_ms(runs, || {
|
||||
if let Some(st) = d.stories.get_mut(&sid) {
|
||||
let st = Arc::make_mut(st);
|
||||
let at = (st.text.len() / 2 + k).min(st.text.len());
|
||||
let at = (0..=at).rev().find(|i| st.text.is_char_boundary(*i)).unwrap_or(0);
|
||||
st.text.insert(at, 'x');
|
||||
if let Some(run) = st.chars.iter_mut().find(|r| r.len > 0) {
|
||||
run.len += 1;
|
||||
}
|
||||
st.rev += 1;
|
||||
k += 1;
|
||||
}
|
||||
drop(r.render(&d, &cache, &all, W, H, v, &greek(HIDPI)));
|
||||
});
|
||||
row(&mut rows, "keystroke: recompose story + repaint at 100%", ms, Some(8.0));
|
||||
}
|
||||
|
||||
let opts = RenderOptions { printing_only: true, ..Default::default() };
|
||||
let mut png = 0usize;
|
||||
let ms = median_ms(runs.min(5), || {
|
||||
let img = r.render_page(doc, &cache, doc.first_page_of_spread(mid), 150.0 / 72.0, true, &opts).expect("page");
|
||||
png = img.to_png().len();
|
||||
});
|
||||
row(&mut rows, format!("export page PNG at 150 dpi ({} kB)", png / 1024), ms, None);
|
||||
|
||||
let mut bytes = vec![];
|
||||
row(
|
||||
&mut rows,
|
||||
format!("save .designcraft ({} pages)", doc.page_count()),
|
||||
median_ms(runs.min(3), || bytes = designcraft_format::save(doc).expect("save")),
|
||||
Some(300.0),
|
||||
);
|
||||
row(&mut rows, "open .designcraft", median_ms(runs.min(3), || drop(designcraft_format::load(&bytes).expect("load"))), Some(300.0));
|
||||
let n = doc.page_count().min(100);
|
||||
let pdf = designcraft_pdf::PdfOptions { pages: Some((0..n).collect()), ..Default::default() };
|
||||
row(
|
||||
&mut rows,
|
||||
format!("PDF export {n} pages"),
|
||||
median_ms(1, || drop(designcraft_pdf::export_pdf(doc, &cache, &pdf).expect("pdf"))),
|
||||
Some(2000.0 * n as f64 / 100.0),
|
||||
);
|
||||
rows
|
||||
}
|
||||
|
||||
fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> {
|
||||
println!("{title}");
|
||||
let mut over = 0;
|
||||
println!(" {:<60} {:>11} {:>10}", "", "measured", "budget");
|
||||
for r in rows {
|
||||
let (b, flag) = match r.budget {
|
||||
Some(b) if r.ms > b => {
|
||||
over += 1;
|
||||
(format!("{b:>7.0} ms"), "OVER")
|
||||
}
|
||||
Some(b) => (format!("{b:>7.0} ms"), "ok"),
|
||||
None => (" — ".into(), ""),
|
||||
};
|
||||
println!(" {:<60} {:>8.2} ms {b} {flag}", r.name, r.ms);
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
println!("{over} budget(s) exceeded");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn header() -> (usize, bool) {
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
println!(
|
||||
"{cores} cores, load average {}, render threads {}",
|
||||
load.map_or("?".into(), |l| format!("{l:.1}")),
|
||||
designcraft_render::default_threads()
|
||||
);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy (load average above ¾ of the cores); wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
(cores, noisy)
|
||||
}
|
||||
|
||||
/// `designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict]`.
|
||||
pub fn perf(args: &[String]) -> Result<(), String> {
|
||||
let mut spec = Spec::default();
|
||||
let mut runs = 7usize;
|
||||
let mut strict = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut num = || it.next().and_then(|v| v.parse::<usize>().ok()).ok_or(format!("{a} needs a number"));
|
||||
match a.as_str() {
|
||||
"--pages" => spec.pages = num()?.max(1),
|
||||
"--frames" => spec.frames_per_page = num()?.max(1),
|
||||
"--chars" => spec.chars = num()?,
|
||||
"--images" => spec.images = num()?,
|
||||
"--runs" => runs = num()?.max(1),
|
||||
"--strict" => strict = true,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
}
|
||||
let (_, noisy) = header();
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(&spec);
|
||||
let frames = spec.pages * spec.frames_per_page;
|
||||
println!(
|
||||
"DesignCraft performance budgets — synthetic: {} pages, {frames} text frames in {} threaded stories, {}k chars, {} images ({} assets), {} gradients (built in {:.0} ms)",
|
||||
spec.pages,
|
||||
doc.stories.len(),
|
||||
doc.stories.values().map(|s| s.text.len()).sum::<usize>() / 1000,
|
||||
spec.images,
|
||||
spec.assets,
|
||||
spec.pages,
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
let rows = measure(&doc, runs);
|
||||
print("", &rows, noisy)?;
|
||||
let over = rows.iter().filter(|r| r.budget.is_some_and(|b| r.ms > b)).count();
|
||||
if strict && over > 0 && !noisy { Err(format!("{over} budget(s) exceeded")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// `designcraft-cli bench FILE [--runs N]`: the same measurements on one document.
|
||||
pub fn bench(args: &[String]) -> Result<(), String> {
|
||||
let mut file = None;
|
||||
let mut runs = 5usize;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--runs" => runs = it.next().and_then(|v| v.parse().ok()).ok_or("--runs needs a number")?,
|
||||
f if file.is_none() && !f.starts_with("--") => file = Some(f.to_string()),
|
||||
other => return Err(format!("unknown bench option `{other}`")),
|
||||
}
|
||||
}
|
||||
let file = file.ok_or("usage: designcraft-cli bench FILE [--runs N]")?;
|
||||
let (_, noisy) = header();
|
||||
let mut s = designcraft_engine::Session::new();
|
||||
let t = Instant::now();
|
||||
s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?;
|
||||
let open = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let doc = s.doc().map_err(|e| e.to_string())?.doc.clone();
|
||||
println!("{file}: {} pages, {} stories, {} assets (opened in {open:.0} ms)", doc.page_count(), doc.stories.len(), doc.assets.len());
|
||||
let rows = measure(&doc, runs);
|
||||
print("", &rows, noisy)
|
||||
}
|
||||
@@ -375,12 +375,67 @@ impl Default for DropShadow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner shadow: a blurred, offset shadow inside the object's edge.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct InnerShadow {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub angle: f64,
|
||||
pub distance: f64,
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that hardens the edge instead of blurring it.
|
||||
pub choke: f64,
|
||||
}
|
||||
|
||||
impl Default for InnerShadow {
|
||||
fn default() -> Self {
|
||||
InnerShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 135.0, distance: 7.0, size: 7.0, choke: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Outer glow: a blurred silhouette around the object, without offset.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct OuterGlow {
|
||||
pub on: bool,
|
||||
pub color: String,
|
||||
pub opacity: f32,
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that grows the silhouette instead of blurring it.
|
||||
pub spread: f64,
|
||||
}
|
||||
|
||||
impl Default for OuterGlow {
|
||||
fn default() -> Self {
|
||||
OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Effects {
|
||||
/// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows
|
||||
/// the silhouette instead of blurring it.
|
||||
pub drop_shadow: DropShadow,
|
||||
/// Basic feather width (0 = off).
|
||||
pub feather: f64,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub inner_shadow: InnerShadow,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub outer_glow: OuterGlow,
|
||||
}
|
||||
|
||||
impl Effects {
|
||||
/// Any effect drawn with soft (raster) filters?
|
||||
pub fn any(&self) -> bool {
|
||||
self.drop_shadow.on || self.feather > 0.0 || self.inner_shadow.on || self.outer_glow.on
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Soft object effects (Effects panel): drop shadow, outer glow, inner shadow and basic feather,
|
||||
//! drawn with vello filter layers.
|
||||
//!
|
||||
//! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded
|
||||
//! pipeline each effect is rendered into an offscreen single-threaded context cropped to its
|
||||
//! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts
|
||||
//! draw directly.
|
||||
//!
|
||||
//! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text,
|
||||
//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2),
|
||||
//! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's
|
||||
//! opacity rides on the filter colour's alpha.
|
||||
//! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top.
|
||||
//! - Feather: the object is masked by its shape inset by half the width and blurred, so the
|
||||
//! edge fades from ~0 at the path to full strength `width` inside.
|
||||
|
||||
use designcraft_doc::Item;
|
||||
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
||||
use vello_cpu::{RenderContext, kurbo};
|
||||
|
||||
use crate::{Frame, Renderer, color_of};
|
||||
|
||||
/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.
|
||||
pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
|
||||
let a = angle.to_radians();
|
||||
Vec2::new(-a.cos() * distance, a.sin() * distance)
|
||||
}
|
||||
|
||||
/// How far (points) the effects of `it` reach outside its geometry.
|
||||
pub(crate) fn outset(it: &Item) -> f64 {
|
||||
let e = &it.effects;
|
||||
let mut o: f64 = 0.0;
|
||||
if e.drop_shadow.on {
|
||||
o = o.max(e.drop_shadow.distance.abs() + e.drop_shadow.size.abs() * 1.6 + 1.0);
|
||||
}
|
||||
if e.outer_glow.on {
|
||||
o = o.max(e.outer_glow.size.abs() * 1.6 + 1.0);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// (blur σ, growth) in points for an effect `size` with `pct` percent hardened.
|
||||
fn split(size: f64, pct: f64) -> (f64, f64) {
|
||||
let size = size.max(0.0);
|
||||
let p = (pct / 100.0).clamp(0.0, 1.0);
|
||||
(size * (1.0 - p) / 2.0, size * p)
|
||||
}
|
||||
|
||||
fn blur(sigma: f64) -> Filter {
|
||||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
|
||||
}
|
||||
|
||||
fn shadow(sigma: f64, color: peniko::Color) -> Filter {
|
||||
Filter::from_primitive(FilterPrimitive::DropShadowOnly {
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
std_deviation: sigma.max(0.0) as f32,
|
||||
color,
|
||||
edge_mode: EdgeMode::None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
|
||||
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
|
||||
let doc = f.doc;
|
||||
let e = &it.effects;
|
||||
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.weight };
|
||||
// Spread-space reach of what the object paints.
|
||||
let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);
|
||||
// The silhouette is the shape when the object paints its area (fill / image); text frames
|
||||
// without a fill cast the shadow of their text only, which can't be grown.
|
||||
let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_));
|
||||
let mut below = vec![];
|
||||
if e.outer_glow.on {
|
||||
let g = &e.outer_glow;
|
||||
below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity));
|
||||
}
|
||||
if e.drop_shadow.on {
|
||||
let d = &e.drop_shadow;
|
||||
below.push((offset(d.angle, d.distance), d.size, d.spread, d.color.clone(), d.opacity));
|
||||
}
|
||||
for (off, size, pct, color, opacity) in below {
|
||||
let Some(c) = doc.resolve_color(&color, 1.0) else { continue };
|
||||
let (sigma, grow) = split(size, pct);
|
||||
let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0)));
|
||||
// The offset moves the geometry, not the filter: vello drops layer content that lies
|
||||
// entirely outside the viewport before filtering.
|
||||
let r = (reach + off).inflate(grow, grow);
|
||||
self.with_filters(ctx, f, r, sigma, |me, c, fr| {
|
||||
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
|
||||
c.set_transform(moved.view);
|
||||
c.push_layer(None, None, None, None, Some(filter));
|
||||
me.draw_body(c, &moved, it, bp, xf, page_name);
|
||||
if grow > 0.0 && solid {
|
||||
c.set_transform(moved.view * xf);
|
||||
c.set_paint(peniko::Color::BLACK);
|
||||
c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round));
|
||||
c.stroke_path(bp);
|
||||
}
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
// The object, feathered or not.
|
||||
if e.feather > 0.0 && it.path.is_closed() {
|
||||
let w = e.feather;
|
||||
self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| {
|
||||
c.set_transform(fr.view * xf);
|
||||
c.push_clip_layer(bp);
|
||||
me.draw_body(c, fr, it, bp, xf, page_name);
|
||||
// Keep the body where the blurred inset silhouette is.
|
||||
c.set_transform(Affine::IDENTITY);
|
||||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, None, None, None, Some(blur(w / 4.0)));
|
||||
c.set_transform(fr.view * xf);
|
||||
c.set_paint(peniko::Color::BLACK);
|
||||
c.fill_path(bp);
|
||||
c.set_transform(Affine::IDENTITY);
|
||||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
|
||||
c.set_transform(fr.view * xf);
|
||||
c.set_stroke(kurbo::Stroke::new(w).with_join(kurbo::Join::Round));
|
||||
c.stroke_path(bp);
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
});
|
||||
} else {
|
||||
self.draw_body(ctx, f, it, bp, xf, page_name);
|
||||
}
|
||||
// Inner shadow, clipped to the shape.
|
||||
if e.inner_shadow.on
|
||||
&& it.path.is_closed()
|
||||
&& let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0)
|
||||
{
|
||||
let s = &e.inner_shadow;
|
||||
let (sigma, choke) = split(s.size, s.choke);
|
||||
let off = offset(s.angle, s.distance);
|
||||
let paint = color_of(&c, s.opacity.clamp(0.0, 1.0));
|
||||
self.with_filters(ctx, f, reach, sigma, |_, c, fr| {
|
||||
c.set_transform(fr.view * xf);
|
||||
c.push_clip_layer(bp);
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, None, None, None, Some(blur(sigma)));
|
||||
// Everything outside the offset shape, so the shadow bleeds in from the edges.
|
||||
let m = Affine::translate(off) * xf;
|
||||
let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px;
|
||||
let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1);
|
||||
let mut shape = bp.clone();
|
||||
shape.apply_affine(m);
|
||||
inv.extend(shape.iter());
|
||||
c.set_paint(paint);
|
||||
c.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
c.fill_path(&inv);
|
||||
c.set_fill_rule(peniko::Fill::NonZero);
|
||||
if choke > 0.0 {
|
||||
c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round));
|
||||
c.stroke_path(&shape);
|
||||
}
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a
|
||||
/// multithreaded context the layer is drawn into an offscreen single-threaded context
|
||||
/// covering `reach` (spread space) plus the blur's spread, then composited back.
|
||||
pub(crate) fn with_filters(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
reach: Rect,
|
||||
sigma: f64,
|
||||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
if !f.mt {
|
||||
return draw(self, ctx, f);
|
||||
}
|
||||
// 3σ of the Gaussian in pixels, plus a pixel of antialiasing.
|
||||
let spread = sigma.max(0.0) * 3.0 / f.px + 2.0;
|
||||
let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread);
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64));
|
||||
if w < 1.0 || h < 1.0 {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (w as u16, h as u16);
|
||||
let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
draw(self, &mut off, &shifted);
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
ctx.set_transform(Affine::translate((x0, y0)));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Decoded placed images, shared by every [`crate::Renderer`] in the process (canvas worker,
|
||||
//! thumbnails, exports), so each asset is decoded once and each mip level built once.
|
||||
//!
|
||||
//! Entries are keyed by the address of the asset's encoded bytes (`Arc<Vec<u8>>`) and hold a
|
||||
//! clone of that `Arc`, so the address can't be reused while cached. Least recently used levels
|
||||
//! are evicted beyond a memory budget.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vello_cpu::Pixmap;
|
||||
|
||||
use crate::{decode_pixmap, halve};
|
||||
|
||||
/// Decoded pixels kept (bytes).
|
||||
const BUDGET: usize = 768 << 20;
|
||||
|
||||
struct Entry {
|
||||
_bytes: Arc<Vec<u8>>,
|
||||
pm: Arc<Pixmap>,
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
map: HashMap<(usize, u8), Entry>,
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
/// Assets that failed to decode (don't retry every frame).
|
||||
bad: HashMap<usize, Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
|
||||
|
||||
fn size_of(pm: &Pixmap) -> usize {
|
||||
pm.width() as usize * pm.height() as usize * 4
|
||||
}
|
||||
|
||||
fn with<R>(f: impl FnOnce(&mut Cache) -> R) -> R {
|
||||
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(g.get_or_insert_with(Cache::default))
|
||||
}
|
||||
|
||||
fn lookup(key: (usize, u8)) -> Option<Arc<Pixmap>> {
|
||||
with(|c| {
|
||||
c.clock += 1;
|
||||
let clock = c.clock;
|
||||
c.map.get_mut(&key).map(|e| {
|
||||
e.stamp = clock;
|
||||
e.pm.clone()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn store(bytes: &Arc<Vec<u8>>, level: u8, pm: Arc<Pixmap>) {
|
||||
with(|c| {
|
||||
c.clock += 1;
|
||||
let key = (Arc::as_ptr(bytes) as usize, level);
|
||||
let n = size_of(&pm);
|
||||
if let Some(old) = c.map.insert(key, Entry { _bytes: bytes.clone(), pm, stamp: c.clock }) {
|
||||
c.bytes -= size_of(&old.pm);
|
||||
}
|
||||
c.bytes += n;
|
||||
if c.bytes > BUDGET {
|
||||
let mut v: Vec<(u64, (usize, u8))> = c.map.iter().map(|(k, e)| (e.stamp, *k)).collect();
|
||||
v.sort_unstable();
|
||||
for (_, k) in v {
|
||||
if c.bytes <= BUDGET * 3 / 4 {
|
||||
break;
|
||||
}
|
||||
if k == key {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = c.map.remove(&k) {
|
||||
c.bytes -= size_of(&e.pm);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Full-resolution decode of `bytes` (cached).
|
||||
pub fn decoded(bytes: &Arc<Vec<u8>>) -> Option<Arc<Pixmap>> {
|
||||
let id = Arc::as_ptr(bytes) as usize;
|
||||
if let Some(pm) = lookup((id, 0)) {
|
||||
return Some(pm);
|
||||
}
|
||||
if with(|c| c.bad.contains_key(&id)) {
|
||||
return None;
|
||||
}
|
||||
// Decode outside the lock: other threads keep drawing cached images meanwhile.
|
||||
match decode_pixmap(bytes) {
|
||||
Some(pm) => {
|
||||
let pm = Arc::new(pm);
|
||||
store(bytes, 0, pm.clone());
|
||||
Some(pm)
|
||||
}
|
||||
None => {
|
||||
with(|c| c.bad.insert(id, bytes.clone()));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic
|
||||
/// `width_pt` points wide.
|
||||
pub fn mip(bytes: &Arc<Vec<u8>>, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
|
||||
let full = decoded(bytes)?;
|
||||
let ppt = full.width() as f64 / width_pt.max(1e-6);
|
||||
let id = Arc::as_ptr(bytes) as usize;
|
||||
let mut pm = full;
|
||||
let mut level = 0u8;
|
||||
while ppt / (1u64 << (level + 1)) as f64 >= on_screen * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 {
|
||||
level += 1;
|
||||
pm = match lookup((id, level)) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let half = Arc::new(halve(&pm));
|
||||
store(bytes, level, half.clone());
|
||||
half
|
||||
}
|
||||
};
|
||||
}
|
||||
Some(pm)
|
||||
}
|
||||
|
||||
/// Drop every decoded image.
|
||||
pub fn clear() {
|
||||
with(|c| {
|
||||
c.map.clear();
|
||||
c.bad.clear();
|
||||
c.bytes = 0;
|
||||
});
|
||||
}
|
||||
+83
-146
@@ -12,9 +12,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_color::BlendMode as DcBlend;
|
||||
use designcraft_compose::{Cache, ComposedStory, FrameText};
|
||||
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, SpreadRef, StrokeAlign, StrokeType};
|
||||
use designcraft_fonts::FontDb;
|
||||
use designcraft_compose::{Cache, ComposedStory};
|
||||
use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef, StoryId, StrokeAlign, StrokeType};
|
||||
use designcraft_geom::corners;
|
||||
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
|
||||
use vello_cpu::kurbo;
|
||||
@@ -23,6 +22,10 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
|
||||
pub use vello_cpu;
|
||||
|
||||
mod fx;
|
||||
pub mod images;
|
||||
mod text;
|
||||
|
||||
/// A rendered image (premultiplied RGBA8, row-major).
|
||||
#[derive(Clone)]
|
||||
pub struct Rendered {
|
||||
@@ -115,11 +118,14 @@ pub struct FrameStats {
|
||||
pub micros: u64,
|
||||
}
|
||||
|
||||
/// Reusable renderer (keeps the render context and decoded images).
|
||||
/// Reusable renderer: keeps the render context, the frame-space glyph paths of composed text
|
||||
/// and (process-wide) decoded images between renders.
|
||||
pub struct Renderer {
|
||||
ctx: Option<RenderContext>,
|
||||
resources: Resources,
|
||||
images: HashMap<(AssetId, usize), Arc<Pixmap>>,
|
||||
glyphs: text::GlyphCache,
|
||||
/// Composed stories looked up during the current render.
|
||||
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
|
||||
pub threads: u16,
|
||||
pub stats: FrameStats,
|
||||
}
|
||||
@@ -130,13 +136,18 @@ impl Default for Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
struct Frame<'a> {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Frame<'a> {
|
||||
doc: &'a Document,
|
||||
cache: &'a Cache,
|
||||
view: Affine,
|
||||
/// Visible area in spread space.
|
||||
visible: Rect,
|
||||
/// Size of a device pixel in points.
|
||||
px: f64,
|
||||
opts: &'a RenderOptions,
|
||||
/// Drawing into a multithreaded context (no filter layers).
|
||||
mt: bool,
|
||||
}
|
||||
|
||||
pub fn default_threads() -> u16 {
|
||||
@@ -155,7 +166,25 @@ pub fn default_threads() -> u16 {
|
||||
|
||||
impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
Self { ctx: None, resources: Resources::new(), images: HashMap::new(), threads: default_threads(), stats: FrameStats::default() }
|
||||
Self {
|
||||
ctx: None,
|
||||
resources: Resources::new(),
|
||||
glyphs: text::GlyphCache::default(),
|
||||
stories: HashMap::new(),
|
||||
threads: default_threads(),
|
||||
stats: FrameStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop cached glyph paths (they are rebuilt on demand).
|
||||
pub fn clear_caches(&mut self) {
|
||||
self.glyphs.clear();
|
||||
self.stories.clear();
|
||||
}
|
||||
|
||||
/// Number of text frames whose glyph paths are cached.
|
||||
pub fn cached_frames(&self) -> usize {
|
||||
self.glyphs.len()
|
||||
}
|
||||
|
||||
/// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels).
|
||||
@@ -173,13 +202,16 @@ impl Renderer {
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
let mut ctx = match self.ctx.take() {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
Some(mut c) if c.width() == w && c.height() == h && c.render_settings().num_threads == self.threads => {
|
||||
c.reset();
|
||||
c
|
||||
}
|
||||
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }),
|
||||
};
|
||||
let mt = ctx.render_settings().num_threads > 0;
|
||||
self.stats = FrameStats::default();
|
||||
self.stories.clear();
|
||||
self.glyphs.tick();
|
||||
if let Some(bg) = opts.background {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
@@ -196,15 +228,17 @@ impl Renderer {
|
||||
if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) {
|
||||
continue;
|
||||
}
|
||||
let f = Frame { doc, cache, view: sview, visible, px, opts };
|
||||
let f = Frame { doc, cache, view: sview, visible, px, opts, mt };
|
||||
self.draw_spread(&mut ctx, &f, pl.spread);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
// Render straight into the returned buffer (no extra copy of the frame).
|
||||
let mut pixels = vec![0u8; w as usize * h as usize * 4];
|
||||
ctx.render(vello_cpu::PixmapMut::new(w, h, &mut pixels).expect("buffer size"), &mut self.resources);
|
||||
self.ctx = Some(ctx);
|
||||
self.stories.clear();
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Render one document page (absolute index) at `scale` px/pt, optionally including bleed.
|
||||
@@ -308,33 +342,53 @@ impl Renderer {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !rect_overlaps(vb, f.visible) && !matches!(it.content, Content::Text(_)) {
|
||||
// Composed text stays inside its frame apart from glyph overhang (italics, swashes, big
|
||||
// initials), so text frames get a margin rather than being exempt from culling.
|
||||
let margin = match it.content {
|
||||
Content::Text(_) => 36.0 + 0.25 * vb.width().max(vb.height()),
|
||||
_ => 0.0,
|
||||
};
|
||||
let fx_outset = if it.effects.any() { fx::outset(it) } else { 0.0 };
|
||||
if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) {
|
||||
return;
|
||||
}
|
||||
self.stats.items += 1;
|
||||
let doc = f.doc;
|
||||
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
|
||||
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
|
||||
}
|
||||
// Drop shadow (simple offset silhouette).
|
||||
if it.effects.drop_shadow.on {
|
||||
let ds = &it.effects.drop_shadow;
|
||||
let a = ds.angle.to_radians();
|
||||
let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance);
|
||||
if let Some(c) = doc.resolve_color(&ds.color, 1.0) {
|
||||
ctx.set_transform(f.view * Affine::translate(off) * xf);
|
||||
ctx.set_paint(color_of(&c, ds.opacity));
|
||||
ctx.fill_path(&bp);
|
||||
}
|
||||
if it.effects.any() {
|
||||
self.draw_item_fx(ctx, f, it, &bp, xf, page_name);
|
||||
} else {
|
||||
self.draw_body(ctx, f, it, &bp, xf, page_name);
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// The composed story for a frame (memoised for the current render: the cache lookup
|
||||
/// validates the whole thread each time).
|
||||
fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
|
||||
let key = (sid, page_name.map(str::to_string));
|
||||
if let Some(cs) = self.stories.get(&key) {
|
||||
return cs.clone();
|
||||
}
|
||||
let cs = f.cache.get(f.doc, sid, page_name);
|
||||
self.stories.insert(key, cs.clone());
|
||||
cs
|
||||
}
|
||||
|
||||
/// Fill, content and stroke of a leaf item (no opacity, blend or effects).
|
||||
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
|
||||
let doc = f.doc;
|
||||
// Fill.
|
||||
if !it.fill.is_none() {
|
||||
ctx.set_transform(f.view * xf);
|
||||
if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() {
|
||||
ctx.fill_path(&bp);
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
@@ -342,12 +396,12 @@ impl Renderer {
|
||||
match &it.content {
|
||||
Content::Graphic(g) => {
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.push_clip_layer(&bp);
|
||||
ctx.push_clip_layer(bp);
|
||||
self.draw_graphic(ctx, f, g, xf);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
Content::Text(tf) => {
|
||||
let cs = f.cache.get(doc, tf.story, page_name);
|
||||
let cs = self.story(f, tf.story, page_name);
|
||||
if let Some(ft) = cs.frame(it.id) {
|
||||
self.draw_text(ctx, f, &cs, ft, xf);
|
||||
}
|
||||
@@ -356,10 +410,7 @@ impl Renderer {
|
||||
}
|
||||
// Stroke.
|
||||
if !it.stroke.is_none() {
|
||||
self.draw_stroke(ctx, f, it, &bp, xf);
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
self.draw_stroke(ctx, f, it, bp, xf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,30 +468,7 @@ impl Renderer {
|
||||
let Some(asset) = f.doc.assets.get(&g.asset) else { return };
|
||||
// Pick a mip level close to the on-screen size.
|
||||
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
|
||||
let full = match self.images.get(&(g.asset, 0)) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
let Some(pm) = decode_pixmap(&asset.data) else { return };
|
||||
let pm = Arc::new(pm);
|
||||
self.images.insert((g.asset, 0), pm.clone());
|
||||
pm
|
||||
}
|
||||
};
|
||||
let ppt = full.width() as f64 / g.size.0.max(1e-6); // pixels per point
|
||||
let need = on_screen * 1.0; // pixels per point on screen
|
||||
let mut level = 0usize;
|
||||
let mut pm = full.clone();
|
||||
while ppt / (1 << (level + 1)) as f64 >= need * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 {
|
||||
level += 1;
|
||||
pm = match self.images.get(&(g.asset, level)) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
let half = Arc::new(halve(&pm));
|
||||
self.images.insert((g.asset, level), half.clone());
|
||||
half
|
||||
}
|
||||
};
|
||||
}
|
||||
let Some(pm) = images::mip(&asset.data, g.size.0, on_screen) else { return };
|
||||
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1);
|
||||
ctx.set_transform(f.view * xf * g.xf);
|
||||
let sx = g.size.0 / pm.width().max(1) as f64;
|
||||
@@ -453,97 +481,6 @@ impl Renderer {
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
|
||||
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &ComposedStory, ft: &FrameText, xf: Affine) {
|
||||
let db = FontDb::global();
|
||||
let doc = f.doc;
|
||||
// Decorations under text (shading) and rules.
|
||||
for d in &ft.decos {
|
||||
if let Some(c) = doc.resolve_color(&d.color, d.tint) {
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.fill_rect(&d.rect);
|
||||
}
|
||||
}
|
||||
let scale = (f.view * xf).determinant().abs().sqrt();
|
||||
// Batch glyph outlines per run style into one path (in frame inner space).
|
||||
let mut batches: HashMap<u32, BezPath> = HashMap::new();
|
||||
let mut lines_deco: Vec<(u32, kurbo::Rect)> = Vec::new();
|
||||
for l in &ft.lines {
|
||||
let greek = f.opts.greek_below_px > 0.0 && l.ascent * scale < f.opts.greek_below_px;
|
||||
if greek {
|
||||
if let (Some(a), Some(_)) = (l.glyphs.first(), l.glyphs.last()) {
|
||||
let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline);
|
||||
lines_deco.push((u32::MAX, r));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for g in &l.glyphs {
|
||||
if !g.visible {
|
||||
continue;
|
||||
}
|
||||
let style = &cs.styles[g.style as usize];
|
||||
if style.underline || style.strikethrough {
|
||||
let w = style.size / 14.0;
|
||||
if style.underline {
|
||||
lines_deco
|
||||
.push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w)));
|
||||
}
|
||||
if style.strikethrough {
|
||||
lines_deco
|
||||
.push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w)));
|
||||
}
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
if outline.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let b = batches.entry(g.style).or_default();
|
||||
for el in outline.elements() {
|
||||
b.push(transform_el(a, *el));
|
||||
}
|
||||
self.stats.glyphs += 1;
|
||||
}
|
||||
}
|
||||
ctx.set_transform(f.view * xf);
|
||||
for (si, bp) in &batches {
|
||||
let st = &cs.styles[*si as usize];
|
||||
if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) {
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
if st.stroke != designcraft_color::swatch::NONE
|
||||
&& let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint)
|
||||
{
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight));
|
||||
ctx.stroke_path(bp);
|
||||
}
|
||||
}
|
||||
for (si, r) in lines_deco {
|
||||
let c = if si == u32::MAX {
|
||||
designcraft_color::Color::gray(0.35)
|
||||
} else {
|
||||
let st = &cs.styles[si as usize];
|
||||
doc.resolve_color(&st.fill, st.fill_tint).unwrap_or(designcraft_color::Color::BLACK)
|
||||
};
|
||||
ctx.set_paint(color_of(&c, if si == u32::MAX { 0.5 } else { 1.0 }));
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl {
|
||||
use kurbo::PathEl::*;
|
||||
match el {
|
||||
MoveTo(p) => MoveTo(a * p),
|
||||
LineTo(p) => LineTo(a * p),
|
||||
QuadTo(p1, p2) => QuadTo(a * p1, a * p2),
|
||||
CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3),
|
||||
ClosePath => ClosePath,
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_overlaps(a: Rect, b: Rect) -> bool {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Composed text → pixels.
|
||||
//!
|
||||
//! Glyph outlines of a frame are transformed into frame space and batched per line and run style
|
||||
//! once, then cached per (composed story allocation, frame id): a composed story is immutable, so
|
||||
//! the cache entry stays valid for as long as the composition cache hands out the same `Arc`.
|
||||
//! Each render only fills the lines that intersect the visible rectangle, and lines smaller than
|
||||
//! the greeking threshold become grey bars without touching any outline.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_compose::{ComposedStory, FrameText, Line};
|
||||
use designcraft_doc::ItemId;
|
||||
use designcraft_fonts::FontDb;
|
||||
use designcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::kurbo;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::{Frame, Renderer, color_of, rect_overlaps};
|
||||
|
||||
/// Cached outlines of one composed line.
|
||||
pub(crate) struct LineGlyphs {
|
||||
/// Frame-space bounds of everything drawn for the line (empty lines: `None`).
|
||||
pub bounds: Option<Rect>,
|
||||
/// Glyph outlines batched per run style.
|
||||
pub runs: Vec<(u32, BezPath)>,
|
||||
/// Underline / strikethrough bars.
|
||||
pub decos: Vec<(u32, Rect)>,
|
||||
pub glyphs: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct FrameGlyphs {
|
||||
pub lines: Vec<LineGlyphs>,
|
||||
/// Path elements held (cache budget).
|
||||
pub elements: usize,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
/// Keeps the composed story alive so its address can't be reused while cached.
|
||||
_story: Arc<ComposedStory>,
|
||||
glyphs: Arc<FrameGlyphs>,
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
/// Path cache keyed by (composed story address, frame id).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GlyphCache {
|
||||
map: HashMap<(usize, ItemId), Entry>,
|
||||
elements: usize,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
/// Roughly 56 bytes per element: ~110 MB of cached outlines.
|
||||
const MAX_ELEMENTS: usize = 2_000_000;
|
||||
|
||||
impl GlyphCache {
|
||||
pub fn tick(&mut self) {
|
||||
self.clock += 1;
|
||||
}
|
||||
|
||||
fn get(&mut self, cs: &Arc<ComposedStory>, ft: &FrameText) -> Arc<FrameGlyphs> {
|
||||
let key = (Arc::as_ptr(cs) as usize, ft.frame);
|
||||
let stamp = self.clock;
|
||||
if let Some(e) = self.map.get_mut(&key) {
|
||||
e.stamp = stamp;
|
||||
return e.glyphs.clone();
|
||||
}
|
||||
let g = Arc::new(build(cs, ft));
|
||||
self.elements += g.elements;
|
||||
self.map.insert(key, Entry { _story: cs.clone(), glyphs: g.clone(), stamp });
|
||||
if self.elements > MAX_ELEMENTS {
|
||||
self.evict();
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Drop least recently used entries (never the ones used by the current render) until the
|
||||
/// cache is at half its budget.
|
||||
fn evict(&mut self) {
|
||||
let mut stamps: Vec<(u64, usize, (usize, ItemId))> = self.map.iter().map(|(k, e)| (e.stamp, e.glyphs.elements, *k)).collect();
|
||||
stamps.sort_unstable_by_key(|s| s.0);
|
||||
for (stamp, n, k) in stamps {
|
||||
if self.elements <= MAX_ELEMENTS / 2 || stamp >= self.clock {
|
||||
break;
|
||||
}
|
||||
self.map.remove(&k);
|
||||
self.elements -= n;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.elements = 0;
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl {
|
||||
use kurbo::PathEl::*;
|
||||
match el {
|
||||
MoveTo(p) => MoveTo(a * p),
|
||||
LineTo(p) => LineTo(a * p),
|
||||
QuadTo(p1, p2) => QuadTo(a * p1, a * p2),
|
||||
CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3),
|
||||
ClosePath => ClosePath,
|
||||
}
|
||||
}
|
||||
|
||||
fn union(r: Option<Rect>, b: Rect) -> Option<Rect> {
|
||||
Some(r.map_or(b, |r| r.union(b)))
|
||||
}
|
||||
|
||||
fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line) -> LineGlyphs {
|
||||
let mut runs: Vec<(u32, BezPath)> = Vec::new();
|
||||
let mut decos = Vec::new();
|
||||
let mut glyphs = 0;
|
||||
for g in &l.glyphs {
|
||||
if !g.visible {
|
||||
continue;
|
||||
}
|
||||
let style = &cs.styles[g.style as usize];
|
||||
if style.underline || style.strikethrough {
|
||||
let w = style.size / 14.0;
|
||||
if style.underline {
|
||||
decos.push((g.style, Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w)));
|
||||
}
|
||||
if style.strikethrough {
|
||||
decos.push((g.style, Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w)));
|
||||
}
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
if outline.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let bp = match runs.iter_mut().find(|r| r.0 == g.style) {
|
||||
Some(r) => &mut r.1,
|
||||
None => {
|
||||
runs.push((g.style, BezPath::new()));
|
||||
&mut runs.last_mut().expect("pushed").1
|
||||
}
|
||||
};
|
||||
for el in outline.elements() {
|
||||
bp.push(transform_el(a, *el));
|
||||
}
|
||||
glyphs += 1;
|
||||
}
|
||||
let mut bounds = None;
|
||||
for (si, bp) in &runs {
|
||||
let st = &cs.styles[*si as usize];
|
||||
let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 };
|
||||
bounds = union(bounds, bp.bounding_box().inflate(sw, sw));
|
||||
}
|
||||
for (_, r) in &decos {
|
||||
bounds = union(bounds, *r);
|
||||
}
|
||||
LineGlyphs { bounds, runs, decos, glyphs }
|
||||
}
|
||||
|
||||
fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs {
|
||||
let db = FontDb::global();
|
||||
let lines: Vec<LineGlyphs> = ft.lines.iter().map(|l| build_line(db, cs, l)).collect();
|
||||
let elements = lines.iter().flat_map(|l| &l.runs).map(|(_, bp)| bp.elements().len()).sum();
|
||||
FrameGlyphs { lines, elements }
|
||||
}
|
||||
|
||||
/// Conservative frame-space box of a line from its metrics (no outlines needed).
|
||||
fn line_box(l: &Line) -> Option<Rect> {
|
||||
let first = l.glyphs.first()?;
|
||||
let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min);
|
||||
let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max);
|
||||
let pad = l.ascent.max(1.0);
|
||||
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad))
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub(crate) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &Arc<ComposedStory>, ft: &FrameText, xf: Affine) {
|
||||
let doc = f.doc;
|
||||
let m = f.view * xf;
|
||||
// Visible area in frame space.
|
||||
if xf.determinant().abs() < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let vis = xf.inverse().transform_rect_bbox(f.visible);
|
||||
// Decorations under text (shading) and rules.
|
||||
for d in &ft.decos {
|
||||
if !rect_overlaps(d.rect, vis) {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = doc.resolve_color(&d.color, d.tint) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.fill_rect(&d.rect);
|
||||
}
|
||||
}
|
||||
let scale = m.determinant().abs().sqrt();
|
||||
let greek_px = f.opts.greek_below_px;
|
||||
let mut greek = BezPath::new();
|
||||
let mut shown: Vec<usize> = Vec::new();
|
||||
for (i, l) in ft.lines.iter().enumerate() {
|
||||
let Some(b) = line_box(l) else { continue };
|
||||
if !rect_overlaps(b, vis) {
|
||||
continue;
|
||||
}
|
||||
if greek_px > 0.0 && l.ascent * scale < greek_px {
|
||||
if let Some(a) = l.glyphs.first() {
|
||||
let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline);
|
||||
if r.width() > 0.0 {
|
||||
greek.extend(r.path_elements(0.1));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
shown.push(i);
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
if !shown.is_empty() {
|
||||
let fg = self.glyphs.get(cs, ft);
|
||||
// Resolve each style's paints once per frame.
|
||||
let mut fills: Vec<Option<Option<peniko::Color>>> = vec![None; cs.styles.len()];
|
||||
let mut fill_of = |si: u32| -> Option<peniko::Color> {
|
||||
*fills[si as usize].get_or_insert_with(|| {
|
||||
let st = &cs.styles[si as usize];
|
||||
doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0))
|
||||
})
|
||||
};
|
||||
for i in shown {
|
||||
let lg = &fg.lines[i];
|
||||
let Some(b) = lg.bounds else { continue };
|
||||
if !rect_overlaps(b, vis) {
|
||||
continue;
|
||||
}
|
||||
self.stats.glyphs += lg.glyphs;
|
||||
for (si, bp) in &lg.runs {
|
||||
if let Some(c) = fill_of(*si) {
|
||||
ctx.set_paint(c);
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
let st = &cs.styles[*si as usize];
|
||||
if st.stroke != designcraft_color::swatch::NONE
|
||||
&& let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint)
|
||||
{
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight));
|
||||
ctx.stroke_path(bp);
|
||||
}
|
||||
}
|
||||
for (si, r) in &lg.decos {
|
||||
ctx.set_paint(fill_of(*si).unwrap_or(peniko::Color::BLACK));
|
||||
ctx.fill_rect(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !greek.elements().is_empty() {
|
||||
ctx.set_paint(color_of(&designcraft_color::Color::gray(0.35), 0.5));
|
||||
ctx.fill_path(&greek);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Golden images for the soft object effects (drop shadow, outer glow, inner shadow, feather),
|
||||
//! plus property checks and a multithreaded == single-threaded check.
|
||||
//!
|
||||
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
|
||||
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
|
||||
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
|
||||
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, Fill, Item, ItemId, ParaFormat, Shape, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect, Vec2, shapes};
|
||||
use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
|
||||
|
||||
const SIZE: u32 = 160;
|
||||
|
||||
fn doc() -> Document {
|
||||
Document::new(&NewDocument { width: 160.0, height: 160.0, facing_pages: false, ..Default::default() })
|
||||
}
|
||||
|
||||
fn rect_item(d: &mut Document, r: Rect, swatch: &str) -> ItemId {
|
||||
let id = ItemId(d.alloc());
|
||||
let lid = d.default_layer();
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
it.fill = Fill::swatch(swatch);
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn render_with(d: &Document, threads: u16) -> Rendered {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), offset: Vec2::ZERO }], SIZE, SIZE, Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> Rendered {
|
||||
render_with(d, 0)
|
||||
}
|
||||
|
||||
fn lum(p: [u8; 4]) -> u32 {
|
||||
p[0] as u32 + p[1] as u32 + p[2] as u32
|
||||
}
|
||||
|
||||
const WHITE: u32 = 765;
|
||||
|
||||
/// Tolerant comparison with `tests/golden/{name}.png` (written when missing or when blessing).
|
||||
fn golden(name: &str, img: &Rendered) {
|
||||
let path = format!("{}/tests/golden/{name}.png", env!("CARGO_MANIFEST_DIR"));
|
||||
let bless = std::env::var_os("DESIGNCRAFT_BLESS").is_some();
|
||||
let Ok(bytes) = std::fs::read(&path).map_err(|_| ()).and_then(|b| if bless { Err(()) } else { Ok(b) }) else {
|
||||
std::fs::create_dir_all(format!("{}/tests/golden", env!("CARGO_MANIFEST_DIR"))).unwrap();
|
||||
std::fs::write(&path, img.to_png()).unwrap();
|
||||
eprintln!("wrote golden {path}");
|
||||
return;
|
||||
};
|
||||
let want = image::load_from_memory(&bytes).unwrap().to_rgba8();
|
||||
assert_eq!((want.width(), want.height()), (img.width, img.height), "{name}: size");
|
||||
let got = img.to_straight();
|
||||
let (mut bad, mut sum, mut worst) = (0usize, 0u64, 0u8);
|
||||
for (a, b) in got.chunks_exact(4).zip(want.as_raw().chunks_exact(4)) {
|
||||
let d = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap();
|
||||
sum += d as u64;
|
||||
worst = worst.max(d);
|
||||
if d > 8 {
|
||||
bad += 1;
|
||||
}
|
||||
}
|
||||
let n = (img.width * img.height) as usize;
|
||||
let mean = sum as f64 / n as f64;
|
||||
if bad * 200 > n || mean > 1.0 {
|
||||
let out = std::env::temp_dir().join(format!("designcraft-golden-{name}.png"));
|
||||
let _ = std::fs::write(&out, img.to_png());
|
||||
panic!("{name}: {bad} pixels differ by > 8 (worst {worst}, mean {mean:.2}); got {}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow_doc(spread: f64) -> Document {
|
||||
let mut d = doc();
|
||||
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0");
|
||||
let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow;
|
||||
ds.on = true;
|
||||
ds.distance = 12.0;
|
||||
ds.angle = 135.0;
|
||||
ds.size = 10.0;
|
||||
ds.spread = spread;
|
||||
ds.opacity = 0.8;
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_shadow_is_soft_offset_and_under_the_object() {
|
||||
let img = render(&shadow_doc(0.0));
|
||||
golden("drop-shadow", &img);
|
||||
// The object is unchanged on top.
|
||||
let c = img.pixel(60, 60);
|
||||
assert!(c[0] < 60 && c[2] > 200, "object on top: {c:?}");
|
||||
// Below-right is shadowed, above-left is not.
|
||||
assert!(lum(img.pixel(104, 70)) < 500, "shadow below-right: {:?}", img.pixel(104, 70));
|
||||
assert_eq!(lum(img.pixel(22, 22)), WHITE);
|
||||
// Soft: the shadow fades out gradually past its hard edge (100 + 8.5 offset).
|
||||
let row: Vec<u32> = (104..130).map(|x| lum(img.pixel(x, 80))).collect();
|
||||
let mid = row.iter().filter(|&&v| v > 60 && v < 700).count();
|
||||
assert!(mid >= 6, "gradual falloff: {row:?}");
|
||||
assert!(row.windows(2).all(|w| w[1] + 2 >= w[0]), "monotonic: {row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_shadow_spread_hardens_and_grows() {
|
||||
let soft = render(&shadow_doc(0.0));
|
||||
let hard = render(&shadow_doc(100.0));
|
||||
golden("drop-shadow-spread", &hard);
|
||||
// Full spread: no blur, the silhouette grows by `size` → solid well past the offset edge.
|
||||
let x = 100 + 8 + 6;
|
||||
assert!(lum(hard.pixel(x, 80)) < lum(soft.pixel(x, 80)), "spread grows the shadow");
|
||||
assert_eq!(lum(hard.pixel(126, 80)), WHITE, "hard edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_shadow_of_text_follows_the_glyphs() {
|
||||
let mut d = doc();
|
||||
let lid = d.default_layer();
|
||||
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 150.0, 150.0), lid, "Shadow", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..6, |f| f.over.size = Some(40.0));
|
||||
let ds = &mut d.item_mut(fid).unwrap().effects.drop_shadow;
|
||||
ds.on = true;
|
||||
ds.distance = 4.0;
|
||||
ds.size = 3.0;
|
||||
let img = render(&d);
|
||||
golden("drop-shadow-text", &img);
|
||||
// No frame-shaped shadow: the bottom-right of the (unfilled) frame is clear.
|
||||
assert_eq!(lum(img.pixel(140, 140)), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_glow_surrounds_the_object() {
|
||||
let mut d = doc();
|
||||
let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]");
|
||||
let g = &mut d.item_mut(id).unwrap().effects.outer_glow;
|
||||
g.on = true;
|
||||
g.size = 12.0;
|
||||
g.color = "C=0 M=100 Y=0 K=0".into();
|
||||
g.opacity = 1.0;
|
||||
let img = render(&d);
|
||||
golden("outer-glow", &img);
|
||||
for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] {
|
||||
let p = img.pixel(x, y);
|
||||
assert!(p[1] < 200 && p[0] > p[1] + 40, "magenta glow at {x},{y}: {p:?}");
|
||||
}
|
||||
assert_eq!(lum(img.pixel(10, 10)), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_shadow_darkens_inside_the_lit_edge() {
|
||||
let mut d = doc();
|
||||
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=0 M=0 Y=100 K=0");
|
||||
let s = &mut d.item_mut(id).unwrap().effects.inner_shadow;
|
||||
s.on = true;
|
||||
s.distance = 8.0;
|
||||
s.size = 8.0;
|
||||
s.opacity = 1.0;
|
||||
let img = render(&d);
|
||||
golden("inner-shadow", &img);
|
||||
// Light from the top-left: the shadow sits inside the top-left edges.
|
||||
assert!(lum(img.pixel(33, 80)) + 100 < lum(img.pixel(126, 80)), "{:?} vs {:?}", img.pixel(33, 80), img.pixel(126, 80));
|
||||
assert!(lum(img.pixel(80, 33)) + 100 < lum(img.pixel(80, 126)));
|
||||
// Nothing outside the shape.
|
||||
assert_eq!(lum(img.pixel(26, 80)), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feather_fades_the_edge_inward() {
|
||||
let mut d = doc();
|
||||
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "[Black]");
|
||||
d.item_mut(id).unwrap().effects.feather = 16.0;
|
||||
let img = render(&d);
|
||||
golden("feather", &img);
|
||||
d.item_mut(id).unwrap().effects.feather = 0.0;
|
||||
let solid = lum(render(&d).pixel(80, 80));
|
||||
let edge = lum(img.pixel(30, 80));
|
||||
let mid = lum(img.pixel(38, 80));
|
||||
let inside = lum(img.pixel(80, 80));
|
||||
assert!(edge > 600, "nearly transparent at the path: {edge}");
|
||||
assert!(mid > solid + 60 && mid < 650, "half way: {mid}");
|
||||
assert!(inside <= solid + 6, "solid inside: {inside} vs {solid}");
|
||||
assert_eq!(lum(img.pixel(27, 80)), WHITE, "nothing outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_of_an_object_outside_the_view_still_reaches_it() {
|
||||
let mut d = doc();
|
||||
let id = rect_item(&mut d, Rect::new(-60.0, 40.0, -2.0, 120.0), "[Paper]");
|
||||
let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow;
|
||||
ds.on = true;
|
||||
ds.angle = 180.0;
|
||||
ds.distance = 20.0;
|
||||
ds.size = 2.0;
|
||||
ds.opacity = 1.0;
|
||||
let img = render(&d);
|
||||
assert!(lum(img.pixel(8, 80)) < 200, "{:?}", img.pixel(8, 80));
|
||||
}
|
||||
|
||||
/// Multithreaded contexts draw effects offscreen; the result must match single-threaded.
|
||||
#[test]
|
||||
fn multithreaded_effects_match_single_threaded() {
|
||||
let mut docs = vec![shadow_doc(0.0), shadow_doc(40.0)];
|
||||
let mut d = doc();
|
||||
rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=0 M=30 Y=60 K=0");
|
||||
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=100 M=0 Y=0 K=0");
|
||||
{
|
||||
let it = d.item_mut(id).unwrap();
|
||||
it.effects.feather = 10.0;
|
||||
it.effects.inner_shadow.on = true;
|
||||
it.effects.outer_glow.on = true;
|
||||
it.effects.drop_shadow.on = true;
|
||||
it.opacity = 0.8;
|
||||
}
|
||||
docs.push(d);
|
||||
for (i, d) in docs.iter().enumerate() {
|
||||
let a = render_with(d, 0);
|
||||
let b = render_with(d, 3);
|
||||
let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap();
|
||||
assert!(worst <= 4, "case {i}: max channel difference {worst}");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,122 @@
|
||||
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
|
||||
//! timings are only meaningful on an idle machine — run with
|
||||
//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, ParaFormat, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect, Vec2};
|
||||
use designcraft_render::{Placed, RenderOptions, Renderer};
|
||||
|
||||
const WORDS: &[&str] = &["grid", "margin", "column", "rhythm", "baseline", "reader", "story", "frame", "quiet", "layout", "type", "texture"];
|
||||
|
||||
/// `pages` pages, each with 6 frames of one threaded story.
|
||||
fn text_doc(pages: usize) -> Document {
|
||||
let mut d = Document::new(&NewDocument { pages, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
let mut text = String::new();
|
||||
for i in 0..pages * 3000 {
|
||||
text.push_str(WORDS[(i * 7 + i / 5) % WORDS.len()]);
|
||||
text.push(if i % 70 == 69 { '\n' } else { ' ' });
|
||||
}
|
||||
let mut prev = None;
|
||||
for abs in 0..pages {
|
||||
let (si, pi) = d.page_loc(abs).unwrap();
|
||||
let x0 = d.spreads[si].pages[pi].x;
|
||||
for k in 0..6 {
|
||||
let (c, r) = ((k % 2) as f64, (k / 2) as f64);
|
||||
let rect = Rect::new(x0 + 36.0 + c * 280.0, 36.0 + r * 240.0, x0 + 296.0 + c * 280.0, 266.0 + r * 240.0);
|
||||
let t = if prev.is_none() { text.as_str() } else { "" };
|
||||
let (id, _) = d.add_text_frame(SpreadRef::Doc(si), rect, lid, t, ParaFormat::default()).unwrap();
|
||||
if let Some(p) = prev {
|
||||
d.thread(p, id).unwrap();
|
||||
}
|
||||
prev = Some(id);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn placed(d: &Document) -> Vec<Placed> {
|
||||
let mut y = 0.0;
|
||||
(0..d.spreads.len())
|
||||
.map(|i| {
|
||||
let b = d.spreads[i].bounds();
|
||||
let p = Placed { spread: SpreadRef::Doc(i), offset: Vec2::new(-b.x0, y) };
|
||||
y += b.height() + 36.0;
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_paths_are_cached_and_offscreen_text_is_skipped() {
|
||||
let d = text_doc(4);
|
||||
let cache = Cache::new();
|
||||
let all = placed(&d);
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
// 200% view of the top-left of the first spread: only a few frames are visible.
|
||||
let view = Affine::scale(2.0);
|
||||
let opts = RenderOptions::default();
|
||||
let a = r.render(&d, &cache, &all, 800, 600, view, &opts);
|
||||
let drawn = r.stats.glyphs;
|
||||
assert!(drawn > 100, "glyphs drawn: {drawn}");
|
||||
assert!(r.cached_frames() <= 4, "only visible frames get glyph paths: {}", r.cached_frames());
|
||||
// Same pixels from the cache.
|
||||
let b = r.render(&d, &cache, &all, 800, 600, view, &opts);
|
||||
assert!(a.pixels == b.pixels);
|
||||
// Culling: far fewer glyphs than the visible frames hold.
|
||||
let whole = r.render(&d, &cache, &all, 2400, 1600, Affine::scale(1.0), &opts);
|
||||
assert!(r.stats.glyphs > drawn * 2, "{} vs {drawn}", r.stats.glyphs);
|
||||
drop(whole);
|
||||
// A new composition (edited story) gets fresh paths.
|
||||
let mut d2 = d.clone();
|
||||
let sid = *d2.stories.keys().next().unwrap();
|
||||
d2.set_story_text(sid, "Edited").unwrap();
|
||||
let c = r.render(&d2, &cache, &all, 800, 600, view, &opts);
|
||||
assert!(r.stats.glyphs < 10);
|
||||
assert!(c.pixels != a.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greeked_text_builds_no_glyph_paths() {
|
||||
let d = text_doc(2);
|
||||
let cache = Cache::new();
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
let opts = RenderOptions { greek_below_px: 6.0, ..Default::default() };
|
||||
let img = r.render(&d, &cache, &placed(&d), 400, 300, Affine::scale(0.3), &opts);
|
||||
assert_eq!(r.stats.glyphs, 0);
|
||||
assert_eq!(r.cached_frames(), 0);
|
||||
// The grey bars are drawn.
|
||||
let grey = (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 240).count();
|
||||
assert!(grey > 200, "{grey}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "wall-clock budget; run on an idle machine"]
|
||||
fn render_text_heavy_spread_at_100_percent_within_budget() {
|
||||
let d = text_doc(8);
|
||||
let cache = Cache::new();
|
||||
let all = placed(&d);
|
||||
let mut r = Renderer::new();
|
||||
// 100% on a HiDPI 2880×1800 canvas = 2 px/pt, centred on the second spread.
|
||||
let view = Affine::scale(2.0) * Affine::translate((0.0, -828.0));
|
||||
let opts = RenderOptions { greek_below_px: 6.0, ..Default::default() };
|
||||
r.render(&d, &cache, &all, 2880, 1800, view, &opts);
|
||||
let mut v: Vec<f64> = (0..9)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
drop(r.render(&d, &cache, &all, 2880, 1800, view, &opts));
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
let median = v[v.len() / 2];
|
||||
eprintln!("render text-heavy spread at 100%, 2880×1800: {median:.2} ms (glyphs {})", r.stats.glyphs);
|
||||
// plan/architecture.md §9: < 16 ms.
|
||||
assert!(median < 16.0, "{median:.2} ms");
|
||||
}
|
||||
Reference in new issue
Block a user