Files
figcraft/crates/render/src/glass.rs
T
RealtaNuaandClaude Opus 5.5 44128f7e97 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) <noreply@anthropic.com>
2026-10-15 23:18:45 +07:00

298 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The glass effect: the backdrop seen through a pane whose edges curve down like a lens.
//!
//! Within `depth` of the outline the surface curves: the backdrop there is bent towards the inside
//! (strongest at the very edge, `refraction` × `depth` far), each colour channel by a slightly
//! different amount (`dispersion`, a rainbow fringe), and the rim catches the light: brightest
//! 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.
use figcraft_doc::Glass;
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::fx;
use crate::shadow::{Outline, spread_alpha};
/// Squared distance transform of one row or column (Felzenszwalb & Huttenlocher): `f` holds 0 at
/// sources and a large value elsewhere; `d` gets each sample's squared distance to the nearest
/// source. `v` and `z` are scratch space (`f.len()` and `f.len() + 1` long).
fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) {
let n = f.len();
// Every index below stays under these lengths (k <= q < n), checked once here.
if n == 0 || d.len() < n || v.len() < n || z.len() < n + 1 {
return;
}
let mut k = 0usize;
v[0] = 0;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
for q in 1..n {
let qf = q as f64;
let mut s;
loop {
let vk = v[k] as f64;
s = ((f[q] + qf * qf) - (f[v[k]] + vk * vk)) / (2.0 * qf - 2.0 * vk);
if s <= z[k] && k > 0 {
k -= 1;
} else {
break;
}
}
if s <= z[k] {
// Only the first parabola is left and this one hides it.
v[0] = q;
z[1] = f64::INFINITY;
} else {
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
}
let mut k = 0usize;
for q in 0..n {
let qf = q as f64;
while z[k + 1] < qf {
k += 1;
}
let dv = qf - v[k] as f64;
d[q] = dv * dv + f[v[k]];
}
}
/// For a `w` × `h` coverage pixmap: each pixel's distance (pixels) from its centre to the edge of
/// the shape (pixels at least half covered), 0 outside.
pub fn inside_distance(cov: &Pixmap) -> Vec<f32> {
let (w, h) = (cov.width() as usize, cov.height() as usize);
const FAR: f64 = 1e20;
let mut g: Vec<f64> = cov.data().chunks_exact(4).map(|p| if p.get(3).copied().unwrap_or(0) >= 128 { FAR } else { 0.0 }).collect();
let n = w.max(h);
let (mut f, mut d, mut v, mut z) = (vec![0f64; n], vec![0f64; n], vec![0usize; n], vec![0f64; n + 1]);
for x in 0..w {
for y in 0..h {
if let (Some(fy), Some(gv)) = (f.get_mut(y), g.get(y * w + x)) {
*fy = *gv;
}
}
edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z);
for y in 0..h {
if let (Some(gv), Some(dy)) = (g.get_mut(y * w + x), d.get(y)) {
*gv = *dy;
}
}
}
for y in 0..h {
let row = y * w;
if let (Some(src), Some(dst)) = (g.get(row..row + w), f.get_mut(..w)) {
dst.copy_from_slice(src);
}
edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z);
if let (Some(dst), Some(src)) = (g.get_mut(row..row + w), d.get(..w)) {
dst.copy_from_slice(src);
}
}
// Centre-to-centre distance to the nearest outside pixel → distance to the edge between.
g.iter().map(|s| if *s > 0.0 { (s.sqrt() - 0.5).max(0.0) as f32 } else { 0.0 }).collect()
}
/// Running-sum box blur of a `w` × `h` field along rows (`stride` 1) or columns (`stride` `w`),
/// radius `r`, zero outside.
fn box_pass(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, rows: bool) {
let (lines, len, step, across) = if rows { (h, w, 1, w) } else { (w, h, w, 1) };
let norm = 1.0 / (2 * r + 1) as f32;
for l in 0..lines {
let base = l * across;
let at = |i: usize| src.get(base + i * step).copied().unwrap_or(0.0);
let mut acc: f32 = (0..=r.min(len.saturating_sub(1))).map(at).sum();
for i in 0..len {
if let Some(o) = dst.get_mut(base + i * step) {
*o = acc * norm;
}
if i + r + 1 < len {
acc += at(i + r + 1);
}
if i >= r {
acc -= at(i - r);
}
}
}
}
/// Unit inward normals of the shape from its inside `dist`ance field (`w` × `h`): the distance's
/// gradient, smoothed over `r` pixels so curved edges bend the backdrop smoothly instead of in the
/// steps of the pixel grid. Zero where there is no direction (outside, or a flat middle).
fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)> {
let at = |x: usize, y: usize| dist.get(y * w + x).copied().unwrap_or(0.0);
let mut gx = vec![0f32; w * h];
let mut gy = vec![0f32; w * h];
for y in 1..h.saturating_sub(1) {
for x in 1..w.saturating_sub(1) {
if let (Some(a), Some(b)) = (gx.get_mut(y * w + x), gy.get_mut(y * w + x)) {
*a = (at(x + 1, y) - at(x - 1, y)) * 0.5;
*b = (at(x, y + 1) - at(x, y - 1)) * 0.5;
}
}
}
let mut tmp = vec![0f32; w * h];
for g in [&mut gx, &mut gy] {
for _ in 0..2 {
box_pass(g, &mut tmp, w, h, r, true);
box_pass(&tmp, g, w, h, r, false);
}
}
gx.iter()
.zip(&gy)
.map(|(x, y)| {
let len = (x * x + y * y).sqrt();
if len > 1e-4 { (x / len, y / len) } else { (0.0, 0.0) }
})
.collect()
}
/// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each
/// channel 0..=255; transparent outside.
fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
let (w, h) = (p.width() as i64, p.height() as i64);
let (x, y) = (x - 0.5, y - 0.5);
let (x0, y0) = (x.floor(), y.floor());
let (tx, ty) = (x - x0, y - y0);
let (x0, y0) = (x0 as i64, y0 as i64);
let data = p.data();
let px = |xi: i64, yi: i64| -> [f32; 4] {
if xi < 0 || yi < 0 || xi >= w || yi >= h {
return [0.0; 4];
}
let i = ((yi * w + xi) * 4) as usize;
match data.get(i..i + 4) {
Some(s) => [f32::from(s[0]), f32::from(s[1]), f32::from(s[2]), f32::from(s[3])],
None => [0.0; 4],
}
};
let (a, b, c, d) = (px(x0, y0), px(x0 + 1, y0), px(x0, y0 + 1), px(x0 + 1, y0 + 1));
std::array::from_fn(|k| {
let top = a[k] + (b[k] - a[k]) * tx;
let bot = c[k] + (d[k] - c[k]) * tx;
top + (bot - top) * ty
})
}
/// 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);
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, r);
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
let light = (ang.sin() as f32, -ang.cos() as f32);
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, 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)?;
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;
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);
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) {
*o = ((v * (1.0 - hl) + 255.0 * hl) * c).round().clamp(0.0, 255.0) as u8;
}
}
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distance_of_a_square() {
let mut p = Pixmap::new(21, 21).unwrap();
let r = tiny_skia::Rect::from_xywh(5.0, 5.0, 11.0, 11.0).unwrap();
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), ..Default::default() };
p.fill_rect(r, &paint, tiny_skia::Transform::identity(), None);
let d = inside_distance(&p);
// Outside: 0. The centre pixel (10, 10) is 5.5 px from each edge.
assert_eq!(d[0], 0.0);
assert!((d[10 * 21 + 10] - 5.5).abs() < 1e-4, "{}", d[10 * 21 + 10]);
// 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);
}
}