From 44128f7e97117d0da7f82142cb50f16fc07bee8e Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 23:18:45 +0700 Subject: [PATCH] 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:?}"); + } } } }