//! Effect maps on the GPU (`photocraft_compose::effects::build_maps`). //! //! Every enabled effect of a layer becomes a small *map program*: a chain of single-channel //! fragment passes over the layer's effect region (shift, dilate, separable Gaussian blur, glow //! ramp, bevel height and shading, contour, stroke band), each mirroring one step of the CPU //! builders. Programs are pure functions of the effect settings and the global light. //! //! Inputs come from the CPU, computed by compose itself so they match it bit for bit: the //! layer's **shape** (`compose::layer_shape`) and the **distance fields** of it that the effects //! use (`compose::effects::distance_field`, an inherently sequential transform). Both are //! computed per layer state and uploaded once, and only where the layer changed: //! //! - damage is the union of the layer's tiles whose copy-on-write `Arc` changed, so a brush dab //! recomputes one 256² tile of shape; //! - a distance field is exact up to the distance its effects can use (beyond it every consumer //! saturates), so it is recomputed over the damage grown by that reach, from a window grown by //! the reach again; large windows split into bands computed in parallel; //! - each program re-runs only over the damage grown by its own reach, and only for effects //! whose settings changed; an unrelated edit (another layer, an adjustment, the effect's colour //! or opacity) reuses everything. use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; use photocraft_compose::effects::BevelPaint; use photocraft_compose::effects::FieldKind; use photocraft_doc::{BevelTechnique, Contour, Effect, GlobalLight, GlowSource, GlowTechnique, Layer, LayerContent, Pattern}; use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::{Surface, Tile}; use crate::plan::{Kernel, stroke_widths}; /// Input of a map stage. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum In { /// The layer's alpha over the region. Shape, /// A distance field of the shape. Field(FieldKind), /// Output of stage `n`. Val(usize), } #[derive(Clone, Debug)] pub(crate) struct Stage { pub kernel: Kernel, pub a: Option, pub b: Option, /// Bound as `layer_tex` (the shape for `× shape` steps). pub s: Option, /// LUT row 0 (blur weights, contour, sine profile). pub lut: Option>>, pub p0: [f32; 4], pub p1: [f32; 4], /// Pattern placement (`p3`: origin, rotation) and `p4` (inverse scale, tile size) for stages /// that sample `pattern` (bevel texture). pub p3: [f32; 4], pub p4: [f32; 4], pub pattern: Option, /// How far (px) the stage reads from its inputs around the output pixel. pub radius: i32, /// Final map index this stage writes (else a temporary). pub out: Option, } /// The passes building one effect's maps. #[derive(Clone, Debug)] pub(crate) struct MapProgram { /// Identity of the program (settings that change the maps; not colours or opacities). pub key: u64, pub maps: usize, pub stages: Vec, /// Distance fields read, with the distance up to which each must be exact. pub fields: Vec<(FieldKind, i32)>, } /// How far a field value depends on the shape, for a field exact up to `reach`. pub(crate) fn field_radius(_kind: FieldKind, reach: i32) -> i32 { // The nearest seed lies within the reach (seeds may start up to 1 px in, at their sub-pixel // edge); stroke fields classify seeds by their 3×3 local coverage, one more pixel. reach + 3 } impl MapProgram { fn empty() -> Self { MapProgram { key: 0, maps: 0, stages: Vec::new(), fields: Vec::new() } } /// Per-stage output window for a shape damage rect `d` (all inside `region`): what each stage /// must recompute so every final map is exact over `d` grown by its reach. `field_reach`: /// the reach each field was computed with. pub fn windows(&self, d: Rect, region: Rect, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec { let n = self.stages.len(); let reach_of = |reach: &[i32], i: Option| match i { Some(In::Val(k)) => reach[k], Some(In::Field(f)) => field_radius(f, field_reach(f)), _ => 0, }; let mut reach = vec![0i32; n]; for (i, s) in self.stages.iter().enumerate() { reach[i] = reach_of(&reach, s.a).max(reach_of(&reach, s.b)).max(reach_of(&reach, s.s)) + s.radius; } let mut need = vec![Rect::EMPTY; n]; for (i, s) in self.stages.iter().enumerate() { if s.out.is_some() { need[i] = d.inflate(reach[i]).intersect(®ion); } } for i in (0..n).rev() { let w = need[i]; if w.is_empty() { continue; } let s = &self.stages[i]; for inp in [s.a, s.b, s.s].into_iter().flatten() { if let In::Val(k) = inp { let grown = w.inflate(s.radius).intersect(®ion); need[k] = if need[k].is_empty() { grown } else { need[k].union(&grown) }; } } } need } /// Physical temporary index per stage (None for final maps), reusing temporaries after /// their last read; returns (assignment, temporaries needed). pub fn temps(&self) -> (Vec>, usize) { let n = self.stages.len(); let mut last_use = vec![0usize; n]; for (i, s) in self.stages.iter().enumerate() { for inp in [s.a, s.b, s.s].into_iter().flatten() { if let In::Val(k) = inp { last_use[k] = last_use[k].max(i); } } } let mut free: Vec = Vec::new(); let mut count = 0; let mut out = vec![None; n]; let mut held: Vec<(usize, usize)> = Vec::new(); for (i, (stage, slot)) in self.stages.iter().zip(out.iter_mut()).enumerate() { // Release temporaries whose last reader was before this stage. held.retain(|&(st, t)| { if last_use[st] < i { free.push(t); false } else { true } }); if stage.out.is_some() { continue; } let t = free.pop().unwrap_or_else(|| { count += 1; count - 1 }); *slot = Some(t); held.push((i, t)); } (out, count) } } struct B { stages: Vec, fields: Vec<(FieldKind, i32)>, } fn stage(kernel: Kernel, a: Option, b: Option, p0: [f32; 4], radius: i32) -> Stage { Stage { kernel, a, b, s: None, lut: None, p0, p1: [0.0; 4], p3: [0.0; 4], p4: [0.0; 4], pattern: None, radius, out: None } } impl B { fn push(&mut self, s: Stage) -> In { self.stages.push(s); In::Val(self.stages.len() - 1) } fn field(&mut self, kind: FieldKind, width: f32) -> In { self.fields.push((kind, reach_for(width))); In::Field(kind) } /// `Map::shifted` by whole pixels (`invert`: of `1 - a`); identity shifts of the plain map /// are skipped. fn shift(&mut self, src: In, dx: f32, dy: f32, outside: f32, invert: bool) -> In { if dx == 0.0 && dy == 0.0 && !invert { return src; } let r = dx.abs().max(dy.abs()) as i32; self.push(stage(Kernel::MShift, Some(src), None, [dx, dy, outside, f32::from(u8::from(invert))], r)) } /// `dilate(src, r)` given `dist_outside(src)`. fn dilate(&mut self, src: In, dist: In, r: f32) -> In { self.push(stage(Kernel::MDilate, Some(src), Some(dist), [r, 0.0, 0.0, 0.0], 0)) } fn blur(&mut self, src: In, size: f32) -> In { let Some(k) = blur_kernel(size) else { return src }; self.conv(src, k) } /// Separable convolution with a symmetric kernel `(radius, weights)` (two `MBlur` passes). fn conv(&mut self, src: In, (r, w): (i32, Vec)) -> In { if r <= 0 { return src; } let w = Arc::new(w); let mut h = stage(Kernel::MBlur, Some(src), None, [0.0, r as f32, 0.0, 0.0], r); h.lut = Some(w.clone()); let h = self.push(h); let mut v = stage(Kernel::MBlur, Some(h), None, [1.0, r as f32, 0.0, 0.0], r); v.lut = Some(w); self.push(v) } fn finish(&mut self, a: In, b: Option, invert: bool, contour: &Contour, times_shape: bool, out: usize) { self.finish_lut(a, b, invert, contour_lut(contour), times_shape, out); } fn finish_lut(&mut self, a: In, b: Option, invert: bool, lut: Option>, times_shape: bool, out: usize) { let flag = |v: bool| f32::from(u8::from(v)); let mut s = stage(Kernel::MFinish, Some(a), b, [flag(b.is_some()), flag(invert), flag(lut.is_some()), flag(times_shape)], 0); s.lut = lut.map(Arc::new); if times_shape { s.s = Some(In::Shape); } s.out = Some(out); self.stages.push(s); } } /// Distance up to which a field must be exact for a band / dilation of width `w` /// (`clamp(w + 0.5 - d)`, with `d` up to 1.5 px beyond the pixel distance). fn reach_for(w: f32) -> i32 { w.clamp(0.0, photocraft_compose::effects::MAX_REACH).ceil() as i32 + 2 } /// `compose::effects::blur`'s kernel (a tent of the effect size): (radius, normalised weights), /// or None when it is the identity. pub(crate) fn blur_kernel(size: f32) -> Option<(i32, Vec)> { if !size.is_finite() { return None; } let k = photocraft_compose::effects::tent_kernel(size); (k.0 > 0).then_some(k) } fn contour_lut(c: &Contour) -> Option> { match c { Contour::Linear => None, Contour::Custom { points, .. } => Some(photocraft_compose::adjust::curve_lut(points)), } } /// `compose::effects::offset`: shadow offset away from the light, whole pixels. fn offset(angle: f32, distance: f32) -> (f32, f32) { let a = angle.to_radians(); ((-a.cos() * distance).round(), (a.sin() * distance).round()) } /// The map program of one enabled effect (`build_maps`), without pattern sources. #[cfg(test)] pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> MapProgram { program_with(e, light, vector_shape, &[], (0.0, 0.0)) } /// The map program of one enabled effect (`build_maps`); bevel textures tile `patterns` from /// `anchor` (`effects::TextureCtx`). pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, patterns: &[Pattern], anchor: (f64, f64)) -> MapProgram { let mut b = B { stages: Vec::new(), fields: Vec::new() }; let maps = match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => { // shadow_map let inner = matches!(e, Effect::InnerShadow(_)); let angle = if s.use_global_light { light.angle } else { s.angle }; let (dx, dy) = offset(angle, s.distance); let src = b.shift(In::Shape, dx, dy, if inner { 1.0 } else { 0.0 }, inner); let (r, bw) = photocraft_compose::effects::spread_split(s.size, s.spread); let mut m = src; if r > 0.0 { // dist_outside of the shifted map is the shifted field (integer offsets; shifted-in // pixels are far away for a drop shadow, inside for an inner shadow). let f = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r); let d = if dx == 0.0 && dy == 0.0 { f } else { b.push(stage(Kernel::MShift, Some(f), None, [dx, dy, if inner { -0.5 } else { 1e10 }, 0.0], dx.abs().max(dy.abs()) as i32)) }; m = b.dilate(src, d, r); } let m = b.blur(m, bw); b.finish(m, None, false, &s.contour, inner, 0); 1 } Effect::OuterGlow(g) | Effect::InnerGlow(g) => { // glow_map let inner = matches!(e, Effect::InnerGlow(_)); let edge = inner && g.source == GlowSource::Edge; let center = inner && g.source == GlowSource::Center; match g.technique { GlowTechnique::Precise => { let d = b.field(if edge { FieldKind::Inside } else { FieldKind::Outside }, g.size); let solid = g.size * g.spread; let soft = (g.size - solid).max(1e-3); let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0)); b.finish_lut(m, None, false, photocraft_compose::effects::glow_lut(g), inner, 0); } GlowTechnique::Softer => { // Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha. let src = if inner { b.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape }; let (r, bw) = photocraft_compose::effects::spread_split(g.size, g.spread); let mut m = src; if r > 0.0 { let d = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r); m = b.dilate(src, d, r); } let m = b.blur(m, bw); b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0); } } 1 } Effect::Satin(s) => { // satin_map let (dx, dy) = offset(s.angle, s.distance); let a = b.shift(In::Shape, dx, dy, 0.0, false); let a = b.conv(a, photocraft_compose::effects::tent_kernel(s.size)); let c = b.shift(In::Shape, -dx, -dy, 0.0, false); let c = b.conv(c, photocraft_compose::effects::tent_kernel(s.size)); b.finish(a, Some(c), s.invert, &s.contour, true, 0); 1 } Effect::BevelEmboss(bv) => { // bevel_maps: height map (tent blur of the shape, or chiselled distance ramps), then // highlight / shadow shading inside and / or outside the shape. let g = photocraft_compose::effects::bevel_geom(bv); let size = bv.size.max(1.0); let paint = |p: BevelPaint| match p { BevelPaint::Outer => 0.0, BevelPaint::Both => 1.0, BevelPaint::Inner => 2.0, }; let mut h = if bv.technique == BevelTechnique::Smooth { b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width)) } else { let din = b.field(FieldKind::Inside, size); let dout = b.field(FieldKind::Outside, size); let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0)); if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h } }; if let Some(c) = &bv.contour { // Contour element: the height through the contour over its range. let lut = photocraft_compose::effects::ranged_lut(&c.contour, c.range).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect()); let mut st = stage(Kernel::MFinish, Some(h), None, [0.0, 0.0, 1.0, 0.0], 0); st.lut = Some(Arc::new(lut)); h = b.push(st); } if let Some(t) = &bv.texture && let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| !p.is_empty()) { // Texture element (`effects::bevel_height`): luminance × depth / unit added. let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) }; let pl = photocraft_compose::pattern::Placement::anchored(anchor, t.link, t.phase, t.scale, 0.0); let (origin, cs, inv) = pl.parts(); let mut st = stage(Kernel::MBevelTex, Some(h), None, [t.depth / unit, f32::from(u8::from(t.invert)), 0.0, 0.0], 0); st.p3 = [origin.0 as f32, origin.1 as f32, cs.0 as f32, cs.1 as f32]; st.p4 = [inv as f32, pat.width as f32, pat.height as f32, 0.0]; st.pattern = Some(pat.clone()); h = b.push(st); } if bv.soften >= 1.0 { h = b.conv(h, photocraft_compose::effects::tent_kernel(bv.soften)); } let (angle, altitude) = if bv.use_global_light { (light.angle, light.altitude) } else { (bv.angle, bv.altitude) }; let (sa, ca) = angle.to_radians().sin_cos(); let (se, ce) = altitude.to_radians().sin_cos(); let lut = contour_lut(&bv.gloss_contour).map(Arc::new); let passes: Vec<(f32, bool)> = match g.paint { BevelPaint::Inner => vec![(g.depth, false)], BevelPaint::Outer => vec![(g.depth, true)], BevelPaint::Both => vec![(g.depth, false), (if g.pillow { -g.depth } else { g.depth }, true)], }; let mut out = 0; for (depth, outer) in &passes { for which in [0.0, 1.0] { let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1); s.p1 = [*depth, f32::from(u8::from(*outer)), which, f32::from(u8::from(lut.is_some()))]; s.lut = lut.clone(); s.s = Some(In::Shape); s.out = Some(out); out += 1; b.stages.push(s); } } out } Effect::Stroke(st) => { // composite_with_effects' stroke bands (maps 0 outside, 1 inside) let (in_w, out_w) = stroke_widths(st); if out_w > 0.0 { let d = b.field(if vector_shape { FieldKind::StrokeOutsideVector } else { FieldKind::StrokeOutside }, out_w); let mut s = stage(Kernel::MStroke, Some(d), None, [out_w, 0.0, 0.0, 0.0], 0); s.out = Some(0); b.stages.push(s); } if in_w > 0.0 { let d = b.field(FieldKind::StrokeInside, in_w); let mut s = stage(Kernel::MStroke, Some(d), None, [in_w, 0.0, 0.0, 0.0], 0); s.out = Some(1); b.stages.push(s); } 2 } Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::PatternOverlay { .. } => return MapProgram::empty(), }; let mut h = std::collections::hash_map::DefaultHasher::new(); for s in &b.stages { (format!("{:?}", s.kernel), s.a, s.b, s.s, s.radius, s.out).hash(&mut h); for v in s.p0.iter().chain(&s.p1).chain(&s.p3).chain(&s.p4) { v.to_bits().hash(&mut h); } if let Some(p) = &s.pattern { (&p.id, &p.name, p.width, p.height).hash(&mut h); for (c, t) in p.surface.tiles() { (c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h); } } if let Some(l) = &s.lut { for v in l.iter() { v.to_bits().hash(&mut h); } } } (maps, &b.fields).hash(&mut h); MapProgram { key: h.finish() | 1, maps, stages: b.stages, fields: b.fields } } // --------------------------------------------------------------------------------------------- // CPU inputs: shape and distance fields /// Row-major crop of the region-sized `shape` to `r` (inside `region`). fn crop(shape: &[f32], region: Rect, r: Rect) -> Vec { let sw = region.width() as usize; let mut out = Vec::with_capacity(r.width() as usize * r.height() as usize); for y in r.y0..r.y1 { let o = (y - region.y0) as usize * sw + (r.x0 - region.x0) as usize; out.extend_from_slice(&shape[o..o + r.width() as usize]); } out } /// Rows `[y0, y1)` of `r` split into bands of about `rows`. fn bands(r: Rect, rows: i32) -> Vec { let mut out = Vec::new(); let mut y = r.y0; while y < r.y1 { out.push(Rect::new(r.x0, y, r.x1, (y + rows).min(r.y1))); y += rows; } out } #[cfg(not(target_arch = "wasm32"))] fn par_map(items: Vec, f: impl Fn(T) -> R + Sync + Send) -> Vec { use rayon::prelude::*; items.into_par_iter().map(f).collect() } #[cfg(target_arch = "wasm32")] fn par_map(items: Vec, f: impl Fn(T) -> R) -> Vec { items.into_iter().map(f).collect() } /// Band height for parallel field / shape computation. const BAND: i32 = 128; /// Field `kind` (exact up to `reach`) over `out` (inside `region`), computed from the /// region-sized `shape`. Values within the reach depend only on the shape within /// [`field_radius`], so each band is computed from a window grown by it. pub(crate) fn field(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: Rect) -> Vec { let halo = field_radius(kind, reach) + 1; let parts = par_map(bands(out, BAND.max(halo)), |band| { let win = band.inflate(halo).intersect(®ion); let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize); crop(&f, win, band) }); parts.concat() } /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. pub(crate) fn shape(doc: &photocraft_doc::Document, layer: &Layer, r: Rect) -> Vec { if r.is_empty() { return Vec::new(); } par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band)).concat() } /// Write `v` (row-major over `r`) into the region-sized `shape`. pub(crate) fn paste(shape: &mut [f32], region: Rect, r: Rect, v: &[f32]) { let sw = region.width() as usize; let w = r.width() as usize; for (row, src) in v.chunks_exact(w).enumerate() { let o = (r.y0 - region.y0) as usize * sw + row * sw + (r.x0 - region.x0) as usize; shape[o..o + w].copy_from_slice(src); } } // --------------------------------------------------------------------------------------------- // Shape identity and damage pub(crate) type Tiles = HashMap>; pub(crate) fn snapshot(s: &Surface) -> Tiles { s.tiles().map(|(c, t)| (*c, t.clone())).collect() } /// Union of the tiles that differ between `old` and `s`. pub(crate) fn damage(old: &Tiles, s: &Surface) -> Rect { let mut d = Rect::EMPTY; let mut add = |r: Rect| d = if d.is_empty() { r } else { d.union(&r) }; for (c, t) in s.tiles() { if old.get(c).is_none_or(|o| !Arc::ptr_eq(o, t)) { add(c.rect()); } } for c in old.keys() { if s.tile(*c).is_none() { add(c.rect()); } } d } /// The surfaces a non-group layer's shape is read from: (content, mask). pub(crate) fn shape_sources(layer: &Layer) -> (Option<&Surface>, Option<&Surface>) { let content = match &layer.content { LayerContent::Fill(f) => layer.fill_cache.as_ref().filter(|c| c.fill == *f).map(|c| &c.surface), _ => layer.surface(), }; let mask = layer.mask.as_ref().filter(|m| m.enabled).map(|m| &m.surface); (content, mask) } /// Everything a non-group layer's shape depends on except tile contents and position. pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); layer.id.0.hash(&mut h); let (content, mask) = shape_sources(layer); match &layer.content { LayerContent::Fill(f) => { format!("{f:?}").hash(&mut h); let fr = photocraft_compose::fill_frame(layer, canvas); (fr.x0, fr.y0, fr.x1, fr.y1, content.is_some()).hash(&mut h); } other => std::mem::discriminant(other).hash(&mut h), } for s in [content, mask].into_iter().flatten() { format!("{:?}{:?}", s.format(), s.default_pixel()).hash(&mut h); } if let Some(m) = &layer.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h); } format!("{:?}", layer.vector_mask).hash(&mut h); h.finish() } /// Deep identity of a group (children, their pixels by tile pointer, settings). pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 { fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher) { format!("{:?}{:?}", s.format(), s.default_pixel()).hash(h); for (c, t) in s.tiles() { (c.tx, c.ty, Arc::as_ptr(t) as usize).hash(h); } } fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { l.id.0.hash(h); (l.visible, l.clipped, l.opacity.to_bits(), l.fill_opacity.to_bits(), format!("{:?}", l.blend)).hash(h); match &l.content { LayerContent::Group(g) => { for c in &g.children { walk(c, h); } } LayerContent::Fill(f) => { format!("{f:?}").hash(h); if let Some(c) = &l.fill_cache { surface_fp(&c.surface, h); } } LayerContent::Adjustment(a) => format!("{a:?}").hash(h), _ => { if let Some(s) = l.surface() { surface_fp(s, h); } } } if let Some(m) = &l.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); surface_fp(&m.surface, h); } format!("{:?}{:?}", l.vector_mask, l.effects).hash(h); h.write_u8(0xfe); } let mut h = std::collections::hash_map::DefaultHasher::new(); (light.angle.to_bits(), light.altitude.to_bits()).hash(&mut h); match &layer.content { LayerContent::Group(g) => { for c in &g.children { walk(c, &mut h); } if let Some(m) = &layer.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h); surface_fp(&m.surface, &mut h); } } _ => walk(layer, &mut h), } h.finish() } #[cfg(test)] #[allow(clippy::unreachable)] // clippy.toml exempts unwrap/expect/panic in tests, not unreachable! mod tests { use super::*; use photocraft_color::BlendMode; use photocraft_doc::FxCommon; #[test] fn programs_key_on_geometry_not_colour() { let light = GlobalLight::default(); let Effect::DropShadow(mut s) = Effect::default_drop_shadow() else { unreachable!() }; let a = program(&Effect::DropShadow(s.clone()), &light, false); s.color = photocraft_color::Color::rgb(1.0, 0.0, 0.0); s.common = FxCommon::new(BlendMode::Screen, 0.3); let b = program(&Effect::DropShadow(s.clone()), &light, false); assert_eq!(a.key, b.key); s.size += 1.0; assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key); } #[test] fn windows_grow_by_reach_and_temps_are_reused() { let light = GlobalLight::default(); let Effect::DropShadow(mut s) = Effect::default_drop_shadow() else { unreachable!() }; s.spread = 0.5; let p = program(&Effect::DropShadow(s), &light, false); let region = Rect::new(-100, -100, 1000, 1000); let d = Rect::new(0, 0, 10, 10); let fr = |_| 5; let w = p.windows(d, region, &fr); let last = *w.last().unwrap(); assert!(last.contains_rect(&d.inflate(7)), "{last:?}"); // Earlier stages need at least as much as later ones read. assert!(w[0].width() >= last.width()); let (t, n) = p.temps(); assert!(n <= 3, "{n} temporaries for {} stages", p.stages.len()); assert!(t.last().unwrap().is_none()); } /// A distance field computed in bands from grown windows equals the whole-region field /// wherever it is within its reach (the property incremental updates rely on). #[test] fn banded_fields_match_whole_region_within_reach() { let region = Rect::new(-20, -10, 300, 290); let (w, h) = (region.width() as usize, region.height() as usize); let mut shape = vec![0.0f32; w * h]; for y in 0..h { for x in 0..w { let (fx, fy) = (x as f32 - 150.0, y as f32 - 140.0); let r = (fx * fx + fy * fy).sqrt() + 8.0 * (fy * 0.07).sin() * (fx * 0.05).cos(); shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 }; } } for kind in [ FieldKind::Outside, FieldKind::Inside, FieldKind::OutsideInverse, FieldKind::StrokeOutside, FieldKind::StrokeInside, FieldKind::StrokeOutsideVector, ] { let reach = 9; let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h); let banded = field(kind, reach, &shape, region, region); let part = Rect::new(40, 30, 170, 120); let partial = field(kind, reach, &shape, region, part); for (i, (a, b)) in whole.iter().zip(&banded).enumerate() { if a.min(*b) < reach as f32 { assert!((a - b).abs() < 1e-5, "{kind:?} at {i}: {a} vs {b}"); } } for (i, b) in partial.iter().enumerate() { let (x, y) = (part.x0 + (i % part.width() as usize) as i32, part.y0 + (i / part.width() as usize) as i32); let a = whole[(y - region.y0) as usize * w + (x - region.x0) as usize]; if a.min(*b) < reach as f32 { assert!((a - b).abs() < 1e-5, "{kind:?} at ({x},{y}): {a} vs {b}"); } } } } }