From 18e3070d4c962a7dc41b1980d3b37d4633607758 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 17:36:58 +0700 Subject: [PATCH 1/4] fix(render): glass looks the same wherever a render cuts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass light falls off with the distance to the edge, read past `depth` (up to ~3 × depth at full splay), but the coverage window around a cut layer was only `depth + 2` wide. With frost and refraction at 0 the backdrop margin is 0 too, so a band or canvas patch edge running through the pane changed the light near it: up to 12 levels between a banded and a whole render. The window now covers the light's reach (where it falls under half a level, clamped at 0 and capped at 512 px) plus the span the normals are smoothed over, and past that reach the light is 0 instead of whatever the window happened to hold. Tests: a cut 50–80 px inside the pane equals the whole render byte for byte (fails on main by 12 levels); light_reach edge cases. Adds an ignored release timing test (glass_bench_tests). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 39 ++++++-- crates/render/src/glass_bench_tests.rs | 123 +++++++++++++++++++++++++ crates/render/src/glass_cache_tests.rs | 71 ++++++++++++++ crates/render/src/lib.rs | 4 + 4 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 crates/render/src/glass_bench_tests.rs create mode 100644 crates/render/src/glass_cache_tests.rs diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 015262ef..7ab72d4d 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -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 { 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,7 @@ 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); let intensity = g.light_intensity as f32; let depth = depth as f32; let mut out = Pixmap::new(w, h)?; @@ -223,7 +238,8 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 // 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); + // Past `reach` the light is under half a level: none, wherever the layer is cut. + let hl = if d < reach { (intensity * spec * (-d / band).exp()).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 +268,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); + } } diff --git a/crates/render/src/glass_bench_tests.rs b/crates/render/src/glass_bench_tests.rs new file mode 100644 index 00000000..64c95835 --- /dev/null +++ b/crates/render/src/glass_bench_tests.rs @@ -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, + 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} |"); +} diff --git a/crates/render/src/glass_cache_tests.rs b/crates/render/src/glass_cache_tests.rs new file mode 100644 index 00000000..4844f764 --- /dev/null +++ b/crates/render/src/glass_cache_tests.rs @@ -0,0 +1,71 @@ +//! 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). +fn pane_over_stripes(g: Glass, radius: f64) -> (Document, 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) +} + +/// 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() +} + +#[test] +fn a_cut_through_the_pane_matches_the_whole_render() { + // Square corners: straight edges on whole pixels raster the same in any cut. + for g in [wide_light(), Glass::default(), Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }] { + let (d, p) = pane_over_stripes(g.clone(), 0.0); + for (cut, diff) in cut_diffs(&d, p) { + assert_eq!(diff, (0, 0), "cut at {cut} px with {g:?}"); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda..7376991b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -438,6 +438,10 @@ mod effects_behaviour_tests; #[cfg(test)] mod gif_tests; #[cfg(test)] +mod glass_bench_tests; +#[cfg(test)] +mod glass_cache_tests; +#[cfg(test)] mod grain_tests; #[cfg(test)] mod group_effects_tests; From bc5b474293a09e15d0105ad56158e987450e7aa0 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 17:43:45 +0700 Subject: [PATCH 2/4] perf(render): sample once where glass doesn't bend Where nothing bends (past `depth`, or with no refraction) the three colour channels sample the backdrop at the same point: one sample instead of three. The rim light's facing and powers are worked out only where the light reaches. Same bytes: an ignored `glass_digests` test prints digests of 30 renders (5 looks, square and rounded, 3 scales), identical before and after this change (and to main). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 29 +++++++++++++++++-------- crates/render/src/glass_cache_tests.rs | 30 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 7ab72d4d..7af74011 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -231,15 +231,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); - // Past `reach` the light is under half a level: none, wherever the layer is cut. - let hl = if d < reach { (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0) } else { 0.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. Past `reach` + // the light is under half a level: none, wherever the layer is cut. + let hl = if d < reach { + 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()).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) { diff --git a/crates/render/src/glass_cache_tests.rs b/crates/render/src/glass_cache_tests.rs index 4844f764..43eda0b5 100644 --- a/crates/render/src/glass_cache_tests.rs +++ b/crates/render/src/glass_cache_tests.rs @@ -59,6 +59,36 @@ fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> { .collect() } +/// 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() { // Square corners: straight edges on whole pixels raster the same in any cut. From 4588832c80441acd96d14fd2b6518d4592e26688 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 22:19:21 +0700 Subject: [PATCH 3/4] test(render): name the glass cut tests for what they check Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/{glass_cache_tests.rs => glass_cut_tests.rs} | 0 crates/render/src/lib.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename crates/render/src/{glass_cache_tests.rs => glass_cut_tests.rs} (100%) diff --git a/crates/render/src/glass_cache_tests.rs b/crates/render/src/glass_cut_tests.rs similarity index 100% rename from crates/render/src/glass_cache_tests.rs rename to crates/render/src/glass_cut_tests.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7376991b..50c6ef53 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -440,7 +440,7 @@ mod gif_tests; #[cfg(test)] mod glass_bench_tests; #[cfg(test)] -mod glass_cache_tests; +mod glass_cut_tests; #[cfg(test)] mod grain_tests; #[cfg(test)] From 44128f7e97117d0da7f82142cb50f16fc07bee8e Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 23:18:45 +0700 Subject: [PATCH 4/4] fix(render): the rim light fades out at its reach instead of stopping (#256) Where the 512 px cap held the light short of half a level, it dropped to 0 in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300, splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap doesn't bite this moves the light by at most half a level. Cut tests now also cover rounded corners and a look under the cap. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 12 ++++--- crates/render/src/glass_cut_tests.rs | 52 +++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 7af74011..70e0d582 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -213,6 +213,10 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let fringe = (g.dispersion * 0.25) as f32; let power = (6.0 - 5.0 * g.splay) 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)?; @@ -242,12 +246,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 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. Past `reach` - // the light is under half a level: none, wherever the layer is cut. - let hl = if d < reach { + // 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()).clamp(0.0, 1.0) + (intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0) } else { 0.0 }; diff --git a/crates/render/src/glass_cut_tests.rs b/crates/render/src/glass_cut_tests.rs index 43eda0b5..43733c7f 100644 --- a/crates/render/src/glass_cut_tests.rs +++ b/crates/render/src/glass_cut_tests.rs @@ -8,8 +8,8 @@ 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). -fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId) { +/// 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()); @@ -23,7 +23,7 @@ fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId) { 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) + (d, p, pane) } /// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel @@ -59,6 +59,27 @@ fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> { .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 = 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] @@ -75,7 +96,7 @@ fn glass_digests() { 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); + 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(); @@ -91,11 +112,24 @@ fn glass_digests() { #[test] fn a_cut_through_the_pane_matches_the_whole_render() { - // Square corners: straight edges on whole pixels raster the same in any cut. - for g in [wide_light(), Glass::default(), Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }] { - let (d, p) = pane_over_stripes(g.clone(), 0.0); - for (cut, diff) in cut_diffs(&d, p) { - assert_eq!(diff, (0, 0), "cut at {cut} px with {g:?}"); + // 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:?}"); + } } } }