Merge branch 'pr-576' into batch/t5

This commit is contained in:
bflatastic
2026-10-16 01:36:12 +07:00
4 changed files with 317 additions and 13 deletions
+55 -13
View File
@@ -177,19 +177,34 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
})
}
/// The furthest in (pixels) the rim light is worth half a level or more: where
/// `255 × intensity × e^(−d / band)` falls to 0.5, at most [`MAX_LIGHT_PX`] (0 for no light).
fn light_reach(band: f64, intensity: f64) -> f64 {
let k = 255.0 * intensity / 0.5;
if k > 1.0 && band.is_finite() { (band * k.ln()).min(MAX_LIGHT_PX) } else { 0.0 }
}
/// The light reaches no further in than this many pixels (a wide light on a deep pane at a high
/// zoom would otherwise need a huge margin of coverage around every cut).
const MAX_LIGHT_PX: f64 = 512.0;
/// 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)]
pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option<Pixmap> {
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
// Coverage with a margin, so distances aren't cut short where the layer is cropped (past the
// margin the edge is further than `depth` away and nothing bends).
let m = depth.ceil() as u32 + 2;
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
let reach = light_reach(band, g.light_intensity);
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
// 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, (depth * 0.1).clamp(1.0, 8.0) as usize);
let normals = smooth_normals(&dist, cw, ch, r);
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
@@ -197,7 +212,11 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let bend_max = (g.refraction * depth) as f32;
let fringe = (g.dispersion * 0.25) as f32;
let power = (6.0 - 5.0 * g.splay) as f32;
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0) as f32;
let (band, reach) = (band as f32, reach as f32);
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
// fades out to 0 at `reach` instead of stopping there.
let floor = (-reach / band).exp();
let fall = 1.0 - floor;
let intensity = g.light_intensity as f32;
let depth = depth as f32;
let mut out = Pixmap::new(w, h)?;
@@ -216,14 +235,26 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
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);
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
let base = [rs[0].min(a), gs[1].min(a), bs[2].min(a), a];
// The rim catches the light where it faces it, and half as much opposite.
let facing = -(inward.0 * light.0 + inward.1 * light.1);
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
let hl = (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0);
let base = if bend == 0.0 {
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
// at the same point, so one sample.
let s = sample(&frosted, bx, by);
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
} else {
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
};
// The rim catches the light where it faces it, and half as much opposite. It fades out
// by `reach` and is none past it, wherever the layer is cut.
let hl = if d < reach && fall > 0.0 {
let facing = -(inward.0 * light.0 + inward.1 * light.1);
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
(intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0)
} else {
0.0
};
let i = (y * w as usize + x) * 4;
if let Some(p) = dst.get_mut(i..i + 4) {
for (o, v) in p.iter_mut().zip(base) {
@@ -252,4 +283,15 @@ mod tests {
// A pixel on the edge is half a pixel from it.
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
}
#[test]
fn light_reach_is_where_the_light_falls_under_half_a_level() {
let r = light_reach(10.0, 1.0);
assert!((255.0 * (-r / 10.0).exp() - 0.5).abs() < 1e-9, "{r}");
// No light, too faint to show, or a band that isn't a number: it reaches nowhere.
for (band, intensity) in [(10.0, 0.0), (10.0, 0.5 / 255.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0)] {
assert_eq!(light_reach(band, intensity), 0.0, "{band} {intensity}");
}
assert_eq!(light_reach(1e6, 1.0), MAX_LIGHT_PX);
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Glass render timings: a 600 × 360 rounded glass pane over coloured stripes, 2400 × 1600 device
//! pixels. Run in release:
//! `cargo test --release -p figcraft-render --lib glass_render_timings -- --ignored --nocapture`.
//!
//! "export" rows render the whole view with `render_view` (exports, thumbnails, the first canvas
//! frame); "canvas" rows go through `Retained`, which re-renders only what changed, as the canvas
//! does; "single" renders without parallel bands (as on the web).
use std::sync::Arc;
use std::time::Instant;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use crate::{Options, Retained, View, Work, render_view, render_view_single};
const RUNS: u32 = 8;
/// Stripes, a card under the pane and the glass pane: (document, page, pane, card).
fn scene() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new("glass bench");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let card = d.create(p, NodeKind::Rectangle, "Card", 420.0, 300.0, 160.0, 120.0).unwrap();
d.get_mut(card).unwrap().fills = vec![Paint::solid(Color::WHITE)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [40.0; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(Glass::default()), visible: true }];
(d, p, pane, card)
}
fn view(origin: (f64, f64), scale: f64) -> View {
View { origin, scale, width: 2400, height: 1600 }
}
/// Mean milliseconds of `RUNS` whole-view renders (`render` of the document and view `step(i)`
/// leaves) after one warm-up.
fn time(
d: &mut Document,
p: NodeId,
render: fn(&Document, NodeId, &View, &Options) -> Option<tiny_skia::Pixmap>,
mut step: impl FnMut(&mut Document, u32) -> View,
) -> f64 {
let v = step(d, 0);
let _ = render(d, p, &v, &Options::default());
let t = Instant::now();
for i in 1..=RUNS {
let v = step(d, i);
assert!(render(d, p, &v, &Options::default()).is_some());
}
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
}
/// Mean milliseconds of `RUNS` canvas frames at `scale`, each after `edit(document, i)`.
fn canvas(d: &Document, p: NodeId, scale: f64, edit: impl Fn(&mut Document, u32)) -> f64 {
let mut r = Retained::default();
let mut doc = Arc::new(d.clone());
let v = view((0.0, 0.0), scale);
let _ = r.render(&doc, p, &v, &Options::default());
let t = Instant::now();
for i in 1..=RUNS {
let mut next = (*doc).clone();
edit(&mut next, i);
doc = Arc::new(next);
let (_, _, work) = r.render(&doc, p, &v, &Options::default()).unwrap();
assert!(matches!(work, Work::Partial { .. }), "{work:?}");
}
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
}
fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
}
#[test]
#[ignore = "timing: run in release with --ignored --nocapture"]
fn glass_render_timings() {
let (mut d, p, pane, card) = scene();
let move_card = move |d: &mut Document, i: u32| d.get_mut(card).unwrap().transform = Affine::translate((420.0 + 4.0 * f64::from(i), 300.0));
let move_pane = move |d: &mut Document, i: u32| d.get_mut(pane).unwrap().transform = Affine::translate((300.0 + 3.0 * f64::from(i), 220.0));
let rows: Vec<(&str, f64)> = vec![
("export: same view again", time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0))),
(
"export: move the card under the glass",
time(&mut d, p, render_view, |d, i| {
move_card(d, i);
view((0.0, 0.0), 2.0)
}),
),
("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)))),
("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)),
(
"canvas: drag a glass slider (light angle)",
canvas(&d, p, 2.0, |d, i| set_glass(d, pane, Glass { light_angle: -45.0 + f64::from(i), ..Glass::default() })),
),
("canvas: drag the pane (zoom 200%)", canvas(&d, p, 2.0, move_pane)),
("canvas: drag the pane (zoom 137%)", canvas(&d, p, 1.37, move_pane)),
(
"canvas: resize the pane",
canvas(&d, p, 2.0, |d, i| {
let n = d.get_mut(pane).unwrap();
n.width = 600.0 + 3.0 * f64::from(i);
}),
),
];
d.get_mut(pane).unwrap().effects.iter_mut().for_each(|e| e.visible = false);
let without = time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0));
println!("glass render timings ({} build, 2400 × 1600 px, mean of {RUNS}, ms)", if cfg!(debug_assertions) { "debug" } else { "release" });
println!("| render | ms |\n|---|---:|");
for (name, ms) in rows {
println!("| {name} | {ms:.1} |");
}
println!("| export: same view, glass hidden | {without:.1} |");
}
+135
View File
@@ -0,0 +1,135 @@
//! Glass renders the same however a render is cut: a band or a canvas patch whose edge runs
//! through a pane shows exactly the pixels of the whole render.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::{Options, View, render_view_single};
/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page, pane).
fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [radius; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
(d, p, pane)
}
/// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel
/// difference and how many bytes differ.
fn diff_rows(full: &Pixmap, y0: u32, part: &Pixmap) -> (u8, usize) {
let row = full.width() as usize * 4;
let a = &full.data()[y0 as usize * row..][..part.data().len()];
a.iter().zip(part.data()).fold((0, 0), |(m, n), (x, y)| (m.max(x.abs_diff(*y)), n + usize::from(x != y)))
}
/// A strong, wide light and nothing that blurs or bends: the light reaches furthest in, and the
/// backdrop effects read nothing past the layer, so a cut through the pane gets no margin.
fn wide_light() -> Glass {
Glass { splay: 1.0, frost: 0.0, refraction: 0.0, light_intensity: 1.0, ..Glass::default() }
}
/// The largest channel difference and bytes differing between the whole render of `d` and renders
/// cut 50 to 80 px inside the pane's top edge (y = 440 px), as bands and canvas patches cut it:
/// rendered with the backdrop reach as a margin above and below, keeping 400 rows from the cut.
fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> {
let opts = Options::default();
let full = render_view_single(d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 2400, height: 1600 }, &opts).unwrap();
let margin = crate::backdrop_reach(crate::max_backdrop_blur(d), 2.0).ceil() as u32 + 1;
[490u32, 505, 520]
.into_iter()
.map(|cut| {
let top = cut - margin;
let v = View { origin: (0.0, f64::from(top) / 2.0), scale: 2.0, width: 2400, height: 400 + 2 * margin };
let part = render_view_single(d, p, &v, &opts).unwrap();
let kept = part.clone_rect(tiny_skia::IntRect::from_xywh(0, margin as i32, 2400, 400).unwrap()).unwrap();
(cut, diff_rows(&full, cut, &kept))
})
.collect()
}
/// A pane deeper than the light may reach, over black and lit from the left: across its middle row
/// each pixel is the light alone. It fades out smoothly where it stops, with no ring.
#[test]
fn the_light_fades_out_where_it_stops() {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap();
d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap();
let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
let n = d.get_mut(pane).unwrap();
n.fills.clear();
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap();
let row = &full.data()[700 * 1400 * 4..][..1400 * 4];
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect();
let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels");
assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]);
}
/// Digests of glass renders over a spread of settings, scales and corners: compare two builds'
/// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`.
#[test]
#[ignore = "prints digests to compare builds"]
fn glass_digests() {
use std::hash::{DefaultHasher, Hash, Hasher};
let looks = [
Glass::default(),
wide_light(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { refraction: 1.0, dispersion: 1.0, frost: 10.0, depth: 60.0, light_intensity: 0.0, ..Glass::default() },
Glass { depth: 1.0, splay: 0.3, light_angle: 120.0, ..Glass::default() },
];
let mut all = DefaultHasher::new();
for (i, g) in looks.into_iter().enumerate() {
for radius in [0.0, 40.0] {
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 };
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
let mut h = DefaultHasher::new();
pix.data().hash(&mut h);
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
pix.data().hash(&mut all);
}
}
}
println!("all: {:016x}", all.finish());
}
#[test]
fn a_cut_through_the_pane_matches_the_whole_render() {
// The last look is deep enough that the light is held to `MAX_LIGHT_PX`.
let looks = [
wide_light(),
Glass::default(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { depth: 300.0, ..wide_light() },
];
// Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a
// cut, and that is the fill, not the glass.
for radius in [0.0, 40.0] {
for g in &looks {
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
if radius > 0.0 {
d.get_mut(pane).unwrap().fills.clear();
}
for (cut, diff) in cut_diffs(&d, p) {
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}");
}
}
}
}
+4
View File
@@ -440,6 +440,10 @@ mod effects_behaviour_tests;
#[cfg(test)]
mod gif_tests;
#[cfg(test)]
mod glass_bench_tests;
#[cfg(test)]
mod glass_cut_tests;
#[cfg(test)]
mod grain_tests;
#[cfg(test)]
mod group_effects_tests;