mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
perf(render): keep glass shapes a later render asks for again
A glass layer's coverage, distance field and normals depend only on its outline and the zoom. A shape asked for again in a later render (the next frame of a drag, a slider drag, a canvas patch, an export of the same view) is now worked out whole, in its own pixel frame (translation reduced to its fraction, in 1/65536 px with carry), and kept: renders reuse it instead of redoing the distance transform and normals for their crop. A shape asked for only once (each step of a zoom, a pane dragged by fractions of a pixel, a resize) is still worked out for just the part shown, so those don't get slower: working out the whole pane when only a corner is in view cost 1.8x before this rule. The cache is bounded in bytes (128 MB, 32 MB on the web), keeps the least recently used last, skips shapes over 4M px (1M on the web), and parallel bands share one computation (OnceLock slot). Renders count once per render_view / render_view_single; bands don't. Output: digests of 30 renders (5 looks, square and rounded, scales 1, 1.37, 2) are identical to main; a cut through a rounded pane now equals the whole render exactly. Release timings (glass_render_timings, 2400 × 1600 px, min of 5): canvas drag under the glass 49.5 → 26.5 ms, glass slider drag 104.8 → 61.6 ms, same view again 94.8 → 54.0 ms; zoom 105.0 → 93.7 ms (no reuse: each zoom is new). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
33d59932f9
commit
e37e0bc20b
+240
-10
@@ -6,9 +6,20 @@
|
||||
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
|
||||
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
|
||||
//! distance transform of the shape's coverage.
|
||||
//!
|
||||
//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a
|
||||
//! later render asks for the same shape at the same zoom they are worked out for the whole shape,
|
||||
//! in its own pixel frame, and kept in a cache of at most [`MAX_CACHED_BYTES`]: moving or editing
|
||||
//! what lies under the glass, dragging its sliders, panning and patching the canvas reuse them.
|
||||
//! Zooming doesn't (each zoom is new).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use figcraft_doc::Glass;
|
||||
use figcraft_geom::Affine;
|
||||
use figcraft_geom::kurbo::Shape as _;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use crate::fx;
|
||||
@@ -188,6 +199,211 @@ fn light_reach(band: f64, intensity: f64) -> f64 {
|
||||
/// zoom would otherwise need a huge margin of coverage around every cut).
|
||||
const MAX_LIGHT_PX: f64 = 512.0;
|
||||
|
||||
/// A shape's coverage (alpha), distance to its edge and smoothed inward normals over a window of
|
||||
/// pixels whose top-left pixel is (`x0`, `y0`).
|
||||
struct Fields {
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
cov: Vec<u8>,
|
||||
dist: Vec<f32>,
|
||||
normals: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// Bytes a field pixel holds: coverage, distance and a normal.
|
||||
const FIELD_PX_BYTES: u64 = 1 + 4 + 8;
|
||||
|
||||
impl Fields {
|
||||
/// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals smoothed
|
||||
/// over `r` pixels.
|
||||
fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> Fields {
|
||||
FIELDS_COMPUTED.with(|n| n.set(n.get() + 1));
|
||||
let dist = inside_distance(cov);
|
||||
let (w, h) = (cov.width() as usize, cov.height() as usize);
|
||||
let normals = smooth_normals(&dist, w, h, r);
|
||||
let cov = cov.data().chunks_exact(4).map(|p| p.get(3).copied().unwrap_or(0)).collect();
|
||||
Fields { x0, y0, w, h, cov, dist, normals }
|
||||
}
|
||||
|
||||
/// Coverage (0–1), distance and inward normal at pixel (`x`, `y`); `None` outside the window.
|
||||
fn at(&self, x: i64, y: i64) -> Option<(f32, f32, (f32, f32))> {
|
||||
let (fx, fy) = (x.checked_sub(self.x0)?, y.checked_sub(self.y0)?);
|
||||
if fx < 0 || fy < 0 || fx >= self.w as i64 || fy >= self.h as i64 {
|
||||
return None;
|
||||
}
|
||||
let i = fy as usize * self.w + fx as usize;
|
||||
Some((f32::from(*self.cov.get(i)?) / 255.0, *self.dist.get(i)?, *self.normals.get(i)?))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FIELDS_COMPUTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times this thread has worked out a glass shape's fields: what a cache miss costs. For
|
||||
/// tests and benchmarks.
|
||||
pub fn glass_fields_computed() -> u64 {
|
||||
FIELDS_COMPUTED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// What a glass shape's fields depend on, with the transform's translation reduced to its fraction
|
||||
/// of a pixel: the same shape at the same zoom has the same key wherever it is and however a render
|
||||
/// cuts it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
struct ShapeKey {
|
||||
path: u64,
|
||||
linear: [u64; 4],
|
||||
frac: (i64, i64),
|
||||
margin: u32,
|
||||
r: usize,
|
||||
}
|
||||
|
||||
/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a
|
||||
/// pane far larger than the view) are worked out for the part a render shows each time.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SHAPE_PX: u64 = 4 << 20;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const MAX_SHAPE_PX: u64 = 1 << 20;
|
||||
/// Bytes the glass shape cache holds at most (less on the web, where memory never shrinks).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CACHED_BYTES: u64 = 128 << 20;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const MAX_CACHED_BYTES: u64 = 32 << 20;
|
||||
|
||||
/// A shape's fields once worked out; parallel bands wanting the same shape wait for one of them.
|
||||
type Slot = Arc<OnceLock<Option<Arc<Fields>>>>;
|
||||
|
||||
/// The shapes' fields by key, at most `budget` bytes; the least recently used go first. A shape is
|
||||
/// kept from the second render that asks for it: one asked for once (each step of a zoom, a pane
|
||||
/// dragged by fractions of a pixel) is cheaper worked out for just the part a render shows.
|
||||
struct ShapeCache {
|
||||
map: HashMap<ShapeKey, (Slot, u64, u64)>,
|
||||
/// Shapes asked for but not kept, by the render that first asked.
|
||||
seen: HashMap<ShapeKey, u64>,
|
||||
held: u64,
|
||||
tick: u64,
|
||||
budget: u64,
|
||||
}
|
||||
|
||||
/// Shapes remembered as asked for once; past this many they are forgotten.
|
||||
const MAX_SEEN: usize = 256;
|
||||
|
||||
impl ShapeCache {
|
||||
fn new(budget: u64) -> Self {
|
||||
Self { map: HashMap::new(), seen: HashMap::new(), held: 0, tick: 0, budget }
|
||||
}
|
||||
|
||||
/// The slot for `key` (filled or about to be) asked for in render number `render`, making room
|
||||
/// for `bytes`; `None` when it isn't kept (first asked for in this render, or too big).
|
||||
fn slot(&mut self, key: &ShapeKey, bytes: u64, render: u64) -> Option<Slot> {
|
||||
self.tick += 1;
|
||||
if let Some((slot, _, used)) = self.map.get_mut(key) {
|
||||
*used = self.tick;
|
||||
return Some(slot.clone());
|
||||
}
|
||||
if bytes > self.budget {
|
||||
return None;
|
||||
}
|
||||
match self.seen.get(key) {
|
||||
Some(first) if *first < render => {
|
||||
self.seen.remove(key);
|
||||
}
|
||||
Some(_) => return None,
|
||||
None => {
|
||||
if self.seen.len() >= MAX_SEEN {
|
||||
self.seen.clear();
|
||||
}
|
||||
self.seen.insert(key.clone(), render);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
while self.held + bytes > self.budget {
|
||||
let oldest = self.map.iter().min_by_key(|(_, (_, _, used))| *used).map(|(k, _)| k.clone())?;
|
||||
if let Some((_, b, _)) = self.map.remove(&oldest) {
|
||||
self.held -= b;
|
||||
}
|
||||
}
|
||||
let slot = Slot::default();
|
||||
self.map.insert(key.clone(), (slot.clone(), bytes, self.tick));
|
||||
self.held += bytes;
|
||||
Some(slot)
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_cache() -> &'static Mutex<ShapeCache> {
|
||||
static C: OnceLock<Mutex<ShapeCache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES)))
|
||||
}
|
||||
|
||||
/// The fields `glass` reads for a `w` × `h` layer whose `shape` maps through `xf` (local → layer
|
||||
/// pixels), exact for distances up to the margin `m`, normals smoothed over `r` pixels, and the
|
||||
/// shift from layer pixels to the fields' pixels.
|
||||
///
|
||||
/// A shape asked for again in a later render, whose window fits [`MAX_SHAPE_PX`], is worked out
|
||||
/// whole, in its own frame (the translation's fraction of a pixel), and kept: a band, a canvas
|
||||
/// patch or the next frame of a drag reads the same fields. Otherwise it is worked out for the part
|
||||
/// shown plus the margin.
|
||||
fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize) -> Option<(Arc<Fields>, (i64, i64))> {
|
||||
if let Some(whole) = whole_shape(shape, xf, m, r) {
|
||||
return Some(whole);
|
||||
}
|
||||
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
|
||||
Some((Arc::new(Fields::new(&cov, -i64::from(m), -i64::from(m), r)), (0, 0)))
|
||||
}
|
||||
|
||||
/// [`shape_fields`] for a whole shape, from the cache; `None` when it isn't kept (first asked for
|
||||
/// in this render, too big, or its transform too far out).
|
||||
fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc<Fields>, (i64, i64))> {
|
||||
let [a, b, c, d, e, f] = xf.as_coeffs();
|
||||
// The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough
|
||||
// that rounding errors in where a render starts land on the same step), its whole pixels (the
|
||||
// shift) and the fraction left; rounding first carries 99.9999999 over to 100.
|
||||
const STEPS: f64 = 65536.0;
|
||||
let q = |v: f64| {
|
||||
let q = (v * STEPS).round();
|
||||
(q.is_finite() && q.abs() < 1e15).then_some(q as i64)
|
||||
};
|
||||
let (qe, qf) = (q(e)?, q(f)?);
|
||||
let shift = (qe.div_euclid(STEPS as i64), qf.div_euclid(STEPS as i64));
|
||||
let frac = (qe.rem_euclid(STEPS as i64), qf.rem_euclid(STEPS as i64));
|
||||
let own = Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]);
|
||||
let bb = (own * shape.0.clone()).bounding_box();
|
||||
let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil());
|
||||
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) {
|
||||
return None;
|
||||
}
|
||||
// The margin in steps of 32 px, so dragging a slider that widens it a little keeps the same
|
||||
// fields (a wider margin only adds exact distances further out: the same pixels).
|
||||
let margin = m.div_ceil(32).checked_mul(32)?;
|
||||
let (rw, rh) = ((x1 - x0) as u64 + 2, (y1 - y0) as u64 + 2);
|
||||
let (ww, wh) = (rw + 2 * u64::from(margin), rh + 2 * u64::from(margin));
|
||||
if ww.checked_mul(wh)? > MAX_SHAPE_PX {
|
||||
return None;
|
||||
}
|
||||
let mut hasher = DefaultHasher::new();
|
||||
crate::draw::hash_path(&shape.0, &mut hasher);
|
||||
matches!(shape.1, tiny_skia::FillRule::EvenOdd).hash(&mut hasher);
|
||||
let key = ShapeKey { path: hasher.finish(), linear: [a, b, c, d].map(f64::to_bits), frac, margin, r };
|
||||
let compute = || {
|
||||
// Rastered against the shape's own bounds (not the window), so the margin can't move a
|
||||
// coverage value.
|
||||
let raster = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((1.0 - x0, 1.0 - y0)) * own, rw as u32, rh as u32)?;
|
||||
let mut win = Pixmap::new(ww as u32, wh as u32)?;
|
||||
let (row, at) = (raster.width() as usize * 4, (margin as usize, margin as usize));
|
||||
let stride = win.width() as usize * 4;
|
||||
for (y, src) in raster.data().chunks_exact(row).enumerate() {
|
||||
let start = (y + at.1) * stride + at.0 * 4;
|
||||
win.data_mut().get_mut(start..start + row)?.copy_from_slice(src);
|
||||
}
|
||||
let origin = (x0 as i64 - 1 - i64::from(margin), y0 as i64 - 1 - i64::from(margin));
|
||||
Some(Fields::new(&win, origin.0, origin.1, r))
|
||||
};
|
||||
let slot =
|
||||
shape_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).slot(&key, ww * wh * FIELD_PX_BYTES, crate::render_number())?;
|
||||
Some((slot.get_or_init(|| compute().map(Arc::new)).clone()?, shift))
|
||||
}
|
||||
|
||||
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
|
||||
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -201,10 +417,7 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
|
||||
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
|
||||
// smoothed from (2r + 1 further).
|
||||
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
|
||||
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
|
||||
let dist = inside_distance(&cov);
|
||||
let (cw, ch) = (cov.width() as usize, cov.height() as usize);
|
||||
let normals = smooth_normals(&dist, cw, ch, r);
|
||||
let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
|
||||
let mut frosted = back.clone();
|
||||
fx::blur(&mut frosted, g.frost / 2.0 * scale);
|
||||
let ang = g.light_angle.to_radians();
|
||||
@@ -221,17 +434,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
|
||||
let depth = depth as f32;
|
||||
let mut out = Pixmap::new(w, h)?;
|
||||
let dst = out.data_mut();
|
||||
let cd = cov.data();
|
||||
let d_at = |x: usize, y: usize| dist.get(y * cw + x).copied().unwrap_or(0.0);
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (mx, my) = (x + m as usize, y + m as usize);
|
||||
let c = f32::from(cd.get((my * cw + mx) * 4 + 3).copied().unwrap_or(0)) / 255.0;
|
||||
let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue };
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let d = d_at(mx, my);
|
||||
let inward = normals.get(my * cw + mx).copied().unwrap_or((0.0, 0.0));
|
||||
let t = (d / depth).min(1.0);
|
||||
let bend = bend_max * (1.0 - t) * (1.0 - t);
|
||||
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
|
||||
@@ -284,6 +492,28 @@ mod tests {
|
||||
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shape_cache_stays_within_its_bytes() {
|
||||
let key = |i: u64| ShapeKey { path: i, linear: [0; 4], frac: (0, 0), margin: 0, r: 1 };
|
||||
let mut c = ShapeCache::new(100);
|
||||
// Kept from the second render that asks, not the first (nor again within the first).
|
||||
assert!(c.slot(&key(1), 40, 1).is_none());
|
||||
assert!(c.slot(&key(1), 40, 1).is_none());
|
||||
assert!(c.slot(&key(1), 40, 2).is_some());
|
||||
for k in [2, 3] {
|
||||
assert!(c.slot(&key(k), 40, 2).is_none());
|
||||
}
|
||||
assert!(c.slot(&key(2), 40, 3).is_some());
|
||||
// Using 1 again makes 2 the least recently used: 2 goes to make room for 3.
|
||||
assert!(c.slot(&key(1), 40, 3).is_some());
|
||||
assert!(c.slot(&key(3), 40, 3).is_some());
|
||||
assert!(c.held <= 100, "{}", c.held);
|
||||
assert!(c.map.contains_key(&key(1)) && c.map.contains_key(&key(3)) && !c.map.contains_key(&key(2)));
|
||||
// More than it holds in all: never kept.
|
||||
assert!(c.slot(&key(4), 101, 3).is_none() && c.slot(&key(4), 101, 4).is_none());
|
||||
assert!(c.held <= 100 && !c.map.contains_key(&key(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_reach_is_where_the_light_falls_under_half_a_level() {
|
||||
let r = light_reach(10.0, 1.0);
|
||||
|
||||
@@ -80,6 +80,18 @@ fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
|
||||
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
}
|
||||
|
||||
/// Writes the bench scene's render (rendered twice, so a kept shape is read) to the PNG named by
|
||||
/// `FIGCRAFT_GLASS_PNG`, to compare builds by eye.
|
||||
#[test]
|
||||
#[ignore = "writes a PNG: FIGCRAFT_GLASS_PNG=<path>"]
|
||||
fn glass_bench_png() {
|
||||
let Ok(path) = std::env::var("FIGCRAFT_GLASS_PNG") else { return };
|
||||
let (d, p, _, _) = scene();
|
||||
let _ = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default());
|
||||
let pix = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default()).unwrap();
|
||||
pix.save_png(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing: run in release with --ignored --nocapture"]
|
||||
fn glass_render_timings() {
|
||||
@@ -96,6 +108,10 @@ fn glass_render_timings() {
|
||||
}),
|
||||
),
|
||||
("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))),
|
||||
(
|
||||
"export: zoomed in, a corner of the pane in view, a new scale every frame",
|
||||
time(&mut d, p, render_view, |_, i| view((700.0, 400.0), 3.0 + 0.013 * f64::from(i))),
|
||||
),
|
||||
("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))),
|
||||
("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)),
|
||||
(
|
||||
|
||||
@@ -99,7 +99,10 @@ fn glass_digests() {
|
||||
let (d, p, _) = pane_over_stripes(g.clone(), radius);
|
||||
for scale in [1.0, 1.37, 2.0] {
|
||||
let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 };
|
||||
// Rendered twice: the second reads the whole shape the first asked for.
|
||||
let first = render_view_single(&d, p, &v, &Options::default()).unwrap();
|
||||
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
|
||||
assert!(first.data() == pix.data(), "glass {i} radius {radius} scale {scale}: the kept shape renders differently");
|
||||
let mut h = DefaultHasher::new();
|
||||
pix.data().hash(&mut h);
|
||||
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
|
||||
@@ -133,3 +136,82 @@ fn a_cut_through_the_pane_matches_the_whole_render() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w` × `h` pane of glass `g` at (`x`, `y`) over stripes, alone on its page: (document, page,
|
||||
/// pane). Each test gives its own size, so tests running at once don't share cached shapes.
|
||||
fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new("glass");
|
||||
let p = d.first_page().unwrap();
|
||||
for i in 0..8u32 {
|
||||
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 40.0, 0.0, 20.0, 400.0).unwrap();
|
||||
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex("2040E0").unwrap())];
|
||||
}
|
||||
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, w, h).unwrap();
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.transform = Affine::translate((x, y));
|
||||
n.corners.radii = [12.0; 4];
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
(d, p, pane)
|
||||
}
|
||||
|
||||
fn render(d: &Document, p: NodeId, origin: (f64, f64), scale: f64) -> Pixmap {
|
||||
render_view_single(d, p, &View { origin, scale, width: 400, height: 300 }, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
/// How many glass shapes `f` works out on this thread.
|
||||
fn computed(f: impl FnOnce()) -> u64 {
|
||||
let before = crate::glass::glass_fields_computed();
|
||||
f();
|
||||
crate::glass::glass_fields_computed() - before
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_fields_render_the_same_pixels_as_fresh_ones() {
|
||||
let (d, p, _) = pane(Glass::default(), 40.0, 30.0, 211.0, 137.0);
|
||||
let mut shots = Vec::new();
|
||||
// The first render works out the part it shows, the next the whole shape (and keeps it), the
|
||||
// ones after reuse it.
|
||||
for want in [1, 1, 0] {
|
||||
assert_eq!(computed(|| shots.push(render(&d, p, (0.0, 0.0), 1.5))), want, "render {}", shots.len() + 1);
|
||||
}
|
||||
assert!(shots.windows(2).all(|w| w[0].data() == w[1].data()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() {
|
||||
let (mut d, p, pane) = pane(Glass::default(), 40.0, 30.0, 223.0, 149.0);
|
||||
let set = |d: &mut Document, g: Glass| d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
// Asked for twice: kept.
|
||||
let _ = render(&d, p, (0.0, 0.0), 1.0);
|
||||
let _ = render(&d, p, (0.0, 0.0), 1.0);
|
||||
// Reused: the look apart from depth, what lies under the glass, panning by whole pixels and
|
||||
// moving the pane by whole pixels.
|
||||
let reused = computed(|| {
|
||||
set(&mut d, Glass { light_angle: 30.0, refraction: 0.2, dispersion: 1.0, frost: 9.0, ..Glass::default() });
|
||||
let _ = render(&d, p, (0.0, 0.0), 1.0);
|
||||
let stripe = d.node(p).unwrap().children[0];
|
||||
d.get_mut(stripe).unwrap().transform = Affine::translate((5.0, 0.0));
|
||||
let _ = render(&d, p, (0.0, 0.0), 1.0);
|
||||
let _ = render(&d, p, (7.0, -3.0), 1.0);
|
||||
d.get_mut(pane).unwrap().transform = Affine::translate((52.0, 31.0));
|
||||
let _ = render(&d, p, (0.0, 0.0), 1.0);
|
||||
});
|
||||
assert_eq!(reused, 0);
|
||||
// Worked out again: another zoom, a move by part of a pixel, another shape, a much deeper edge.
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.25))), 1, "zoom");
|
||||
d.get_mut(pane).unwrap().transform = Affine::translate((52.5, 31.0));
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "part of a pixel");
|
||||
d.get_mut(pane).unwrap().width = 230.0;
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "shape");
|
||||
set(&mut d, Glass { depth: 70.0, ..Glass::default() });
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "depth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pane_too_big_to_keep_still_renders() {
|
||||
// 400 × 300 units at 8x: far past what is kept whole, so each render works out its part.
|
||||
let (d, p, _) = pane(Glass { frost: 0.0, ..Glass::default() }, 0.0, 0.0, 400.0, 300.0);
|
||||
let shown = render(&d, p, (10.0, 10.0), 8.0);
|
||||
assert_eq!(computed(|| drop(render(&d, p, (10.0, 10.0), 8.0))), 1);
|
||||
assert!(shown.data().chunks_exact(4).any(|px| px[2] > 200), "the stripes show through");
|
||||
}
|
||||
|
||||
@@ -99,8 +99,23 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 {
|
||||
if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Renders started (bands of one render count once): glass keeps the shapes a later render asks
|
||||
/// for again.
|
||||
static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The number of the render that just started.
|
||||
fn start_render() -> u64 {
|
||||
RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
|
||||
}
|
||||
|
||||
/// The number of the latest render started.
|
||||
pub(crate) fn render_number() -> u64 {
|
||||
RENDERS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Render a page region, splitting it into horizontal bands rendered in parallel (desktop).
|
||||
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
|
||||
start_render();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let threads = rayon::current_num_threads().clamp(1, 16) as u32;
|
||||
@@ -112,7 +127,7 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) ->
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
render_view_single(doc, page, view, opts)
|
||||
render_one(doc, page, view, opts)
|
||||
}
|
||||
|
||||
/// Render `view` as `bands` horizontal bands in parallel. Bands overlap by what background blurs
|
||||
@@ -137,7 +152,7 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands
|
||||
let top = y0.saturating_sub(overlap);
|
||||
let bottom = (y0 + h + overlap).min(view.height).max(y0 + h);
|
||||
let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top };
|
||||
let part = render_view_single(doc, page, &v, opts);
|
||||
let part = render_one(doc, page, &v, opts);
|
||||
let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h);
|
||||
(y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) })
|
||||
})
|
||||
@@ -161,6 +176,12 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands
|
||||
|
||||
/// Render a page region on the calling thread.
|
||||
pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
|
||||
start_render();
|
||||
render_one(doc, page, view, opts)
|
||||
}
|
||||
|
||||
/// [`render_view_single`] as part of a render already started (one of its bands).
|
||||
fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
|
||||
let mut pix = new_pixmap(view.width, view.height)?;
|
||||
let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
|
||||
Some(NodeKind::Page { background, .. }) => Some(*background),
|
||||
|
||||
Reference in New Issue
Block a user