diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 6a473a2d..ecdd4bd9 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -70,6 +70,37 @@ fn typical() -> DevelopSettings { s } +/// [`typical`] plus three masks: a radial gradient, a brush (three strokes) and either a linear +/// gradient with Refine Edges (`refine`; such edits render on the CPU) or a Subject mask (the +/// heuristic, which the GPU renderer evaluates on the CPU). +fn with_masks(refine: bool) -> DevelopSettings { + use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let mut s = typical(); + let mask = |id: u32, shape: MaskShape, exposure: f64, refine: f64| Mask { + id, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure, clarity: 10.0, ..Default::default() }, + refine, + ..Default::default() + }; + let radial = MaskShape::Radial { center: Point::new(0.45, 0.5), rx: 0.3, ry: 0.2, angle: 15.0, feather: 60.0, invert: false }; + let strokes = (0..3) + .map(|i| BrushStroke { + points: (0..40).map(|k| Point::new(0.1 + k as f64 * 0.02, 0.2 + i as f64 * 0.25 + (k as f64 * 0.3).sin() * 0.05)).collect(), + size: 0.04, + ..Default::default() + }) + .collect(); + let third = if refine { + mask(3, MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, -0.5, 60.0) + } else { + mask(3, MaskShape::Subject, 0.3, 0.0) + }; + s.masks = vec![mask(1, radial, 0.5, 0.0), mask(2, MaskShape::Brush { strokes }, -0.3, 0.0), third]; + s +} + /// Max and mean |Δ| (8-bit, RGB) between two renders. fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) { let (mut max, mut sum) = (0u8, 0u64); @@ -214,6 +245,32 @@ fn main() { drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g)); }); } + if run("masks") { + // three masks (one with Refine Edges, or one Subject mask): a global exposure drag and a + // mask's own exposure drag, both of which leave the masks' alphas as they were + for refine in [true, false] { + let m = with_masks(refine); + let what = if refine { "masks+refine" } else { "masks+subject" }; + let caches = [StageCache::default(), StageCache::default()]; + for (req, size) in [(&view, "loupe 1920×1280"), (&draft, "draft 1152×768")] { + for g in backends { + drop(develop(&preview, &info, &m, req, Some(&caches[*g as usize]), *g)); + } + row(&format!("{size} exposure drag, {what}:"), &mut |g| { + let mut t = m.clone(); + k += 1.0; + t.light.exposure = 0.3 + (k % 50.0) * 0.01; + drop(develop(&preview, &info, &t, req, Some(&caches[g as usize]), g)); + }); + row(&format!("{size} mask exposure drag, {what}:"), &mut |g| { + let mut t = m.clone(); + k += 1.0; + t.masks[0].adjust.exposure = 0.5 + (k % 50.0) * 0.01; + drop(develop(&preview, &info, &t, req, Some(&caches[g as usize]), g)); + }); + } + } + } if run("finish") { // per-pixel stage cost by feature (warm cache: exposure changes only) let variants: [(&str, fn(&mut DevelopSettings)); 6] = [ diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350..f4b7e9fa 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -9,6 +9,7 @@ use lightcraft_geom::Orientation; use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; use lightcraft_pipeline::geometry::SampleMode; use lightcraft_pipeline::local::plane_at; +use lightcraft_pipeline::masks::{AlphaCache, AlphaKeys, alpha_key}; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; use lightcraft_raster::resample::Filter; use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8}; @@ -34,6 +35,9 @@ struct Entry { sampled: Arc, lin: Option<(u64, Arc)>, planes: Planes, + /// Alphas of the last render's masks, as [`lightcraft_pipeline::masks::AlphaCache`] keeps them + /// on the CPU (same keys). + masks: AlphaCache, } /// Spatial planes of one linear image (`key` = its `lin_key`), each tagged with its radius. @@ -106,6 +110,8 @@ impl GpuStages { p.clarity.iter().for_each(|(_, b)| add(b)); p.texture.iter().for_each(|(_, b)| add(b)); p.dark.iter().for_each(|(_, b, _)| add(b)); + p.chroma.iter().for_each(|(_, b)| add(b)); + e.masks.planes().for_each(&mut add); } if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) { add(b); @@ -644,6 +650,7 @@ pub fn render( lap("wb/nr", &mut t, &mut cx); // 3. spatial planes + let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default(); let mut planes = match cached.map(|e| e.planes) { Some(p) if p.key == plan.lin_key => p, _ => Planes { key: plan.lin_key, ..Default::default() }, @@ -654,13 +661,13 @@ pub fn render( prep.air = a; } lap("planes", &mut t, &mut cx); - if let Some(c) = stages { - c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes }); - } // 4. masks - let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host); + let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host, &mut alphas); lap("masks", &mut t, &mut cx); + if let Some(c) = stages { + c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes, masks: alphas }); + } // 5. per-pixel stage let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); @@ -924,14 +931,25 @@ fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, s /// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated, /// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment -/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. -fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option, Vec<[f32; MASK_TERMS]>) { +/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. A mask whose alpha `alphas` +/// holds (same [`alpha_key`]) is copied from there; `alphas` keeps this render's. +fn masks( + cx: &mut Cx<'_>, + lin: &Buf, + prep: &Prep, + plan: &Plan<'_>, + host: &mut Host, + alphas: &mut AlphaCache, +) -> (Option, Vec<[f32; MASK_TERMS]>) { use lightcraft_develop::{MaskOp, MaskShape}; let s = &*plan.settings; let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + let old = alphas.take(plan.mattes.as_ref()); if list.is_empty() { return (None, Vec::new()); } + let keys = AlphaKeys { geo: plan.geo, image: plan.lin_key }; + let mut used = Vec::with_capacity(list.len()); let (w, h) = (plan.w, plan.h); let n = w * h; let frame = &plan.frame; @@ -946,6 +964,12 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } let comp_plane = cx.gpu.buffer(n); for (mi, m) in list.iter().enumerate() { + let key = alpha_key(m, keys, ev); + if let Some((_, b)) = old.iter().find(|(k, _)| *k == key) { + cx.copy_into(b, &alpha, mi * n); + used.push((key, b.clone())); + continue; + } let mut first = true; for comp in &m.components { let op = match comp.op { @@ -1055,7 +1079,9 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho &[None, None, None, Some(&comp_plane), Some(&alpha)], groups1(n), ); + used.push((key, Arc::new(cx.slice(&alpha, mi * n, n)))); } + alphas.keep(plan.mattes.as_ref(), used); let terms = list.iter().map(|m| mask_terms(&m.adjust)).collect(); (Some(alpha), terms) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a97..ed0c6fb9 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -553,6 +553,67 @@ fn cached_renders_match_uncached() { } } +/// Mask alphas kept on the device across a slider drag: a warm render equals a fresh one whatever +/// the edit (one each mask reads, and ones none of them read), the CPU's result within the bounds, +/// and the alphas count in the stages' device bytes. +#[test] +fn cached_renders_with_masks_match_uncached() { + if !gpu() { + return; + } + let src = scene(1, 900, 600); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let req = RenderRequest::fit(640, 640); + let comp = |shape: MaskShape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }; + let mask = |id: u32, shape: MaskShape| Mask { id, components: vec![comp(shape)], ..Default::default() }; + let mut s = DevelopSettings::default(); + typical(&mut s); + let stroke = BrushStroke { points: (0..10).map(|k| Point::new(0.1 + 0.07 * k as f64, 0.6)).collect(), size: 0.04, ..Default::default() }; + s.masks = vec![ + mask(1, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false }), + mask(2, MaskShape::Brush { strokes: vec![stroke] }), + mask(3, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 }), + // no kernel: evaluated on the CPU and uploaded + mask(4, MaskShape::Subject), + ]; + for (i, m) in s.masks.iter_mut().enumerate() { + m.adjust.exposure = 0.3 * i as f64 - 0.4; + m.adjust.clarity = 20.0; + } + let steps: Vec> = vec![ + Box::new(|_| {}), + Box::new(|s| s.light.exposure = 0.8), + Box::new(|s| s.masks[0].adjust.exposure = 1.0), + Box::new(|s| s.masks[3].adjust.amount = 50.0), + Box::new(|s| { + s.masks[0].components[0].shape = + MaskShape::Radial { center: Point::new(0.6, 0.4), rx: 0.2, ry: 0.2, angle: 0.0, feather: 30.0, invert: false } + }), + Box::new(|s| s.masks.swap(1, 2)), + Box::new(|s| { + s.wb.mode = WbMode::Custom; + s.wb.temp = 4300.0; + }), + Box::new(|s| s.masks[1].visible = false), + ]; + for (i, step) in steps.iter().enumerate() { + step(&mut s); + let warm = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu"); + let fresh = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu"); + assert_eq!(warm.image, fresh.image, "step {i}"); + } + check("masks after a cached drag", &src, &info, &s, &req); + // three visible masks of 4 B/px held on the device besides the stages + let out = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu").image; + let n = out.width * out.height; + let with = lightcraft_gpu::stage_bytes(&cache); + s.masks.clear(); + let fresh = StageCache::default(); + lightcraft_gpu::render(&src, &info, &s, &req, Some(&fresh)).expect("gpu"); + assert!(with >= lightcraft_gpu::stage_bytes(&fresh) + 3 * 4 * n, "{with}"); +} + /// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log /// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and /// a warm cache never hands one process the other's base. diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 27d5d054..c7998bcb 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -236,9 +236,10 @@ pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_cro /// /// Each stage is keyed by exactly the inputs it depends on: the resampled source by the source /// buffer, framing and output size; the white-balanced, retouched, denoised image by those plus -/// white balance, spots and noise reduction; each spatial plane by that plus its own radius. So a -/// tone or colour slider drag (or exposure) re-runs only the per-pixel stage, a clarity drag skips -/// resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a +/// white balance, spots and noise reduction; each spatial plane by that plus its own radius; each +/// mask's alpha by its definition, the framing and, for masks that read them, that image and the +/// exposure ([`masks::alpha_key`]). So a tone or colour slider drag (or exposure) re-runs only the +/// per-pixel stage, a clarity drag skips resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a /// full-size render of the same view both stay warm). pub struct StageCache { entries: Mutex>, @@ -300,9 +301,12 @@ impl StageCache { } let pl = &e.planes; let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1)); - for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) { + for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)).chain(e.masks.planes()) { add(Arc::as_ptr(p) as usize, size(p)); } + if let Some((_, c)) = &pl.chroma { + add(Arc::as_ptr(c) as usize, size(c)); + } } total } @@ -341,6 +345,9 @@ struct CacheEntry { sampled: Arc, lin: Option<(u64, Arc)>, planes: local::Planes, + /// Alphas of the last render's masks (kept across changes to the image: those of masks that + /// don't read it stay valid). + masks: masks::AlphaCache, } fn hash_of(parts: impl std::hash::Hash) -> u64 { @@ -563,6 +570,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende } }; lap("wb/spots/nr", &mut t); + let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default(); let mut planes = match cached.map(|e| e.planes) { Some(p) if p.key == lin_key => p, // A draft while noise reduction changes (a slider drag): the wide, edge-aware planes of the @@ -572,13 +580,14 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(), _ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() }, }; - let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref()); + let keys = masks::AlphaKeys { geo, image: lin_key }; + let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_ref(), (&mut alphas, keys)); if let Some(a) = plan.fixed_air { prep.air = a; } lap("prepare", &mut t); if let Some((a, c)) = shared { - c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); + c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes, masks: alphas }); } if req.depth != OutputDepth::U8 { let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof); @@ -646,7 +655,7 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { let m = o.mask(&plan.settings)?; if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { - return Some(e.alpha.clone()); + return Some((*e.alpha).clone()); } let ev = plan.settings.light.exposure as f32; Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())) diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index ce3994cd..ea9bd616 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -479,8 +479,8 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS PlaneSigmas { base, base_detail, clarity, texture, dark, chroma } } -/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), -/// reusing whatever `planes` already holds for it. Missing planes are computed side by side (each +/// The spatial planes and mask alphas the per-pixel stage needs for `img` (white-balanced, before +/// exposure), reusing whatever `planes` and `alphas` already hold for it. Missing planes are computed side by side (each /// one alone scales poorly: the guided filters work on small subsampled grids). #[allow(clippy::too_many_arguments)] pub(crate) fn prepare( @@ -490,7 +490,8 @@ pub(crate) fn prepare( px_per_long: f64, q: Quality, planes: &mut Planes, - mattes: Option<&masks::Mattes>, + mattes: Option<&Arc>, + alphas: (&mut masks::AlphaCache, masks::AlphaKeys), ) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = @@ -536,7 +537,8 @@ pub(crate) fn prepare( None => (None, 1.0), }; let ev = s.light.exposure as f32; - let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes)); + let (cache, keys) = alphas; + let masks = timed("masks", || masks::evaluate_cached(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes, keys, cache)); Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long } } diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 0628a6a6..6f5811d3 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -9,6 +9,7 @@ use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape, SegMask}; use lightcraft_geom::{Orientation, Point}; use lightcraft_raster::{Image, Plane, Rgb32f}; +use std::sync::Arc; use crate::for_rows; use crate::geometry::Frame; @@ -95,7 +96,8 @@ impl Mattes { pub struct Evaluated { /// The mask's id ([`Mask::id`]). pub id: u32, - pub alpha: Plane, + /// Shared with the [`AlphaCache`] that keeps it for the next render. + pub alpha: Arc, pub adjust: LocalAdjustments, } @@ -111,10 +113,176 @@ pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, masks .iter() .filter(|m| m.visible && !m.components.is_empty()) - .map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev, mattes), adjust: m.adjust }) + .map(|m| Evaluated { id: m.id, alpha: Arc::new(evaluate_one(m, frame, w, h, img, log_l, ev, mattes)), adjust: m.adjust }) .collect() } +/// What a render's masks are evaluated against besides their own definitions: [`alpha_key`] keys +/// each alpha by the parts of it the mask reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AlphaKeys { + /// The output frame and size (`Plan::geo`): every mask depends on it. + pub geo: u64, + /// The image the masks see, before exposure (`Plan::lin_key`): range masks, the Sky / Subject + /// / Background heuristics, Auto Mask brushes and Refine Edges read it. + pub image: u64, +} + +/// Alpha planes (`P`: a CPU [`Plane`], or a renderer's device buffer) of the last render's visible +/// masks, keyed by [`alpha_key`]. A slider drag that changes nothing a mask reads (Exposure for +/// gradients and brushes, Clarity, the mask's own sliders except Amount) reuses its alpha instead +/// of evaluating it again. Holds only the masks of the last render (one plane per visible mask), +/// tied to the segmentation mattes they were evaluated with. +pub struct AlphaCache

{ + mattes: Option>, + slots: Vec<(u64, Arc

)>, +} + +impl

Clone for AlphaCache

{ + fn clone(&self) -> Self { + AlphaCache { mattes: self.mattes.clone(), slots: self.slots.clone() } + } +} + +impl

Default for AlphaCache

{ + fn default() -> Self { + AlphaCache { mattes: None, slots: Vec::new() } + } +} + +impl

AlphaCache

{ + /// The alphas held for a render with `mattes` (none when the mattes changed), taken out of + /// the cache: hand back the ones the render used with [`Self::keep`]. + pub fn take(&mut self, mattes: Option<&Arc>) -> Vec<(u64, Arc

)> { + let same = match (&self.mattes, mattes) { + (None, None) => true, + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + _ => false, + }; + let slots = std::mem::take(&mut self.slots); + if same { slots } else { Vec::new() } + } + + /// Hold `slots` (the alphas a render with `mattes` used), dropping everything else. + pub fn keep(&mut self, mattes: Option<&Arc>, slots: Vec<(u64, Arc

)>) { + self.mattes = mattes.cloned(); + self.slots = slots; + } + + /// The alphas held. + pub fn planes(&self) -> impl Iterator> { + self.slots.iter().map(|(_, p)| p) + } + + pub fn len(&self) -> usize { + self.slots.len() + } + + pub fn is_empty(&self) -> bool { + self.slots.is_empty() + } +} + +/// Whether the alpha of mask `m` reads the image (`.0`) and the global exposure (`.1`), beyond +/// its own definition and the frame. Errs towards yes: a mask said to read something it doesn't +/// is only evaluated more often than it needs to be. +pub fn reads(m: &Mask) -> (bool, bool) { + let (mut image, mut exposure) = (m.refine > 0.0, false); + for c in &m.components { + let (i, e) = match &c.shape { + MaskShape::Linear { .. } | MaskShape::Radial { .. } | MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => (false, false), + MaskShape::Prompt { .. } => (false, false), + // a stored segmentation (or no clicks) reads nothing; clicks without one take the heuristic + MaskShape::Object { seg, detail, .. } => (seg.is_none() && detail.is_empty(), false), + MaskShape::Brush { strokes } => (strokes.iter().any(|s| s.auto_mask), false), + // (with a source matte these read neither: counted as if they did) + MaskShape::LuminanceRange { .. } | MaskShape::ColorRange { .. } | MaskShape::Sky => (true, true), + MaskShape::Subject | MaskShape::Background | MaskShape::People { .. } => (true, false), + }; + image |= i; + exposure |= e; + } + (image, exposure) +} + +/// Feeds `Debug` output to a hasher without building the string. +struct HashWrite<'a, H: std::hash::Hasher>(&'a mut H); + +impl std::fmt::Write for HashWrite<'_, H> { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.write(s.as_bytes()); + Ok(()) + } +} + +/// The key of mask `m`'s alpha in a render with `keys` and global exposure `ev`: everything +/// [`evaluate_one`] reads — its components, Refine Edges, Invert and Amount, the frame, and the +/// image and exposure for the masks that read them ([`reads`]). Not its other sliders, name or id. +pub fn alpha_key(m: &Mask, keys: AlphaKeys, ev: f32) -> u64 { + use std::fmt::Write; + use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher, Hash, Hasher}; + let (image, exposure) = reads(m); + let mut h = BuildHasherDefault::::default().build_hasher(); + keys.geo.hash(&mut h); + image.then_some(keys.image).hash(&mut h); + exposure.then_some(ev.to_bits()).hash(&mut h); + (m.refine.to_bits(), m.invert, m.adjust.amount.to_bits()).hash(&mut h); + // (`Debug` of an f64 round-trips: distinct definitions write distinct text) + let _ = write!(HashWrite(&mut h), "{:?}", m.components); + h.finish() +} + +/// [`evaluate`], reusing the alphas `cache` holds for masks whose inputs are unchanged and keeping +/// this render's there. The result is identical to [`evaluate`]'s. +#[allow(clippy::too_many_arguments)] +pub fn evaluate_cached( + masks: &[Mask], + frame: &Frame, + w: usize, + h: usize, + img: &Rgb32f, + log_l: &Plane, + ev: f32, + mattes: Option<&Arc>, + keys: AlphaKeys, + cache: &mut AlphaCache, +) -> Vec { + let old = cache.take(mattes); + let mut used = Vec::new(); + let out = masks + .iter() + .filter(|m| m.visible && !m.components.is_empty()) + .map(|m| { + let key = alpha_key(m, keys, ev); + let alpha = match old.iter().find(|(k, _)| *k == key) { + Some((_, a)) => a.clone(), + None => Arc::new(evaluate_one(m, frame, w, h, img, log_l, ev, mattes.map(|a| &**a))), + }; + used.push((key, alpha.clone())); + Evaluated { id: m.id, alpha, adjust: m.adjust } + }) + .collect(); + cache.keep(mattes, used); + out +} + +/// `f(a, b)` for each value `a` of `p` and the value `b` at the same index of `q`, row-parallel. +fn zip_rows(p: &mut Plane, q: &Plane, f: impl Fn(&mut f32, f32) + Sync + Send) { + let w = p.width; + for_rows(&mut p.data, w, |y, row| { + let Some(src) = q.data.get(y * w..y * w + row.len()) else { return }; + for (a, b) in row.iter_mut().zip(src) { + f(a, *b); + } + }); +} + +/// `f` on each value of `p`, row-parallel. +fn map_rows(p: &mut Plane, f: impl Fn(&mut f32) + Sync + Send) { + let w = p.width; + for_rows(&mut p.data, w, |_, row| row.iter_mut().for_each(&f)); +} + /// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and /// scaled by its amount. #[allow(clippy::too_many_arguments)] @@ -124,19 +292,17 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l for comp in &m.components { let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev, mattes); if comp.invert { - c.data.iter_mut().for_each(|v| *v = 1.0 - *v); + map_rows(&mut c, |v| *v = 1.0 - *v); } if first && comp.op != MaskOp::Intersect { alpha = c; first = false; continue; } - for (a, c) in alpha.data.iter_mut().zip(&c.data) { - *a = match comp.op { - MaskOp::Add => a.max(*c), - MaskOp::Subtract => *a * (1.0 - c), - MaskOp::Intersect => *a * c, - }; + match comp.op { + MaskOp::Add => zip_rows(&mut alpha, &c, |a, c| *a = a.max(c)), + MaskOp::Subtract => zip_rows(&mut alpha, &c, |a, c| *a *= 1.0 - c), + MaskOp::Intersect => zip_rows(&mut alpha, &c, |a, c| *a *= c), } first = false; } @@ -146,16 +312,15 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32; let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0); let refined = guided_cross(log_l, &alpha, sigma, 0.02); - for (a, r) in alpha.data.iter_mut().zip(&refined.data) { - *a += (r - *a) * k.sqrt(); - } + let t = k.sqrt(); + zip_rows(&mut alpha, &refined, |a, r| *a += (r - *a) * t); } if m.invert { - alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); + map_rows(&mut alpha, |v| *v = 1.0 - *v); } let amt = (m.adjust.amount / 100.0) as f32; if (amt - 1.0).abs() > 1e-6 { - alpha.data.iter_mut().for_each(|v| *v *= amt); + map_rows(&mut alpha, |v| *v *= amt); } alpha } @@ -210,37 +375,42 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out), MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => { let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3)); - for (v, l) in out.data.iter_mut().zip(&log_l.data) { + zip_rows(&mut out, log_l, |v, l| { // map log luminance to a 0..1 perceptual scale (−8..+4 EV) let y = ((l + ev + 8.0) / 12.0).clamp(0.0, 1.0); *v = smooth(lo - lf, lo, y) * (1.0 - smooth(hi, hi + hf, y)); - } + }); } MaskShape::ColorRange { samples, refine } => { let tol = color_range_tolerance(*refine); let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect(); - for (v, c) in out.data.iter_mut().zip(&img.data) { - let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain))); - let d = samples - .iter() - .map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt()) - .fold(f32::MAX, f32::min); - *v = 1.0 - smooth(tol * 0.5, tol, d); - } + for_rows(&mut out.data, w, |y, row| { + let Some(src) = img.data.get(y * w..y * w + row.len()) else { return }; + for (v, c) in row.iter_mut().zip(src) { + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain))); + let d = samples + .iter() + .map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt()) + .fold(f32::MAX, f32::min); + *v = 1.0 - smooth(tol * 0.5, tol, d); + } + }); } MaskShape::Sky => { // Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or // unsaturated, and connected to the top of the frame. let m = frame.out_to_norm(w, h); - for (i, v) in out.data.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let c = img.data[i].map(|v| v * gain); - let l = log_l.data[i] + ev; - let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); - let top = 1.0 - smooth(0.25, 0.7, n.y as f32); - *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); - } + for_rows(&mut out.data, w, |y, row| { + let (Some(cs), Some(ls)) = (img.data.get(y * w..y * w + row.len()), log_l.data.get(y * w..y * w + row.len())) else { return }; + for (x, (v, (c, l))) in row.iter_mut().zip(cs.iter().zip(ls)).enumerate() { + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let c = c.map(|v| v * gain); + let l = l + ev; + let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); + let top = 1.0 - smooth(0.25, 0.7, n.y as f32); + *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); + } + }); smooth_plane(&mut out, 0.01 * frame_px(frame, w)); } MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => { @@ -260,18 +430,20 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R // Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12). let m = frame.out_to_norm(w, h); let blur = lightcraft_raster::blur::gaussian(log_l, 0.03 * frame_px(frame, w)); - for (i, v) in out.data.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32; - let contrast = (log_l.data[i] - blur.data[i]).abs(); - *v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast)); - } + for_rows(&mut out.data, w, |y, row| { + let (Some(ls), Some(bs)) = (log_l.data.get(y * w..y * w + row.len()), blur.data.get(y * w..y * w + row.len())) else { return }; + for (x, (v, (l, b))) in row.iter_mut().zip(ls.iter().zip(bs)).enumerate() { + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32; + let contrast = (l - b).abs(); + *v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast)); + } + }); smooth_plane(&mut out, 0.015 * frame_px(frame, w)); } MaskShape::Background => { let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l, ev, mattes); - s.data.iter_mut().for_each(|v| *v = 1.0 - *v); + map_rows(&mut s, |v| *v = 1.0 - *v); out = s; } MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => {} @@ -570,19 +742,37 @@ pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { let mp = gaussian(p, sigma); let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma); let cii = gaussian(&guide.map(|a| a * a), sigma); + let w = p.width; + let rows = |y: usize, len: usize| y * w..y * w + len; + // a = cov(I, p) / (var(I) + eps), b = mean(p) − a · mean(I) let mut a = Plane::new(p.width, p.height); - let mut b = Plane::new(p.width, p.height); - for i in 0..p.data.len() { - let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0); - let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps); - a.data[i] = k; - b.data[i] = mp.data[i] - k * mi.data[i]; - } - let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma)); - let mut q = Plane::new(p.width, p.height); - for i in 0..q.data.len() { - q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0); - } + for_rows(&mut a.data, w, |y, row| { + let r = rows(y, row.len()); + let (Some(cii), Some(mi), Some(cip), Some(mp)) = (cii.data.get(r.clone()), mi.data.get(r.clone()), cip.data.get(r.clone()), mp.data.get(r)) + else { + return; + }; + for (k, (((cii, mi), cip), mp)) in row.iter_mut().zip(cii.iter().zip(mi).zip(cip).zip(mp)) { + let var = (cii - mi * mi).max(0.0); + *k = (cip - mi * mp) / (var + eps); + } + }); + let mut b = mp; + for_rows(&mut b.data, w, |y, row| { + let r = rows(y, row.len()); + let (Some(ks), Some(mi)) = (a.data.get(r.clone()), mi.data.get(r)) else { return }; + for (b, (k, mi)) in row.iter_mut().zip(ks.iter().zip(mi)) { + *b -= k * mi; + } + }); + let (ma, mut q) = (gaussian(&a, sigma), gaussian(&b, sigma)); + for_rows(&mut q.data, w, |y, row| { + let r = rows(y, row.len()); + let (Some(ma), Some(g)) = (ma.data.get(r.clone()), guide.data.get(r)) else { return }; + for (q, (a, g)) in row.iter_mut().zip(ma.iter().zip(g)) { + *q = (a * g + *q).clamp(0.0, 1.0); + } + }); q } @@ -626,9 +816,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i // the refinement fills gaps along edges; it never takes paint away stroke_alpha = stroke_alpha.zip_map(&q, f32::max); } - for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) { - *o = if erase { *o * (1.0 - a) } else { o.max(*a) }; - } + zip_rows(out, &stroke_alpha, |o, a| *o = if erase { *o * (1.0 - a) } else { o.max(a) }); } } @@ -1015,4 +1203,70 @@ mod tests { let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); } + + /// The alpha cache's key changes with everything an alpha is evaluated from (a stale key would + /// put a stale mask on screen), and not with the mask's other sliders. + #[test] + fn alpha_keys_change_with_every_input_of_the_alpha() { + let keys = AlphaKeys { geo: 1, image: 2 }; + let comp = |shape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }; + let one = |shape| Mask { components: vec![comp(shape)], ..Default::default() }; + let radial = one(MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false }); + let lum = one(MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 }); + let key = |m: &Mask| alpha_key(m, keys, 0.0); + // a stored segmentation of the same size with other logits + let object = |v: f32| { + let seg = Some(SegMask::from_logits(4, &[v; 16])); + one(MaskShape::Object { hint: vec![Point::new(0.5, 0.5)], exclude: vec![], seg, detail: vec![], edge: 0.0 }) + }; + assert_ne!(key(&object(4.0)), key(&object(-4.0))); + let edits: [fn(&mut Mask); 7] = [ + |m| m.refine = 10.0, + |m| m.invert = true, + |m| m.adjust.amount = 50.0, + |m| m.components[0].invert = true, + |m| m.components[0].op = MaskOp::Intersect, + |m| { + if let MaskShape::Radial { feather, .. } = &mut m.components[0].shape { + *feather = 50.5; + } + }, + |m| { + let shape = MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(0.0, 1.0) }; + m.components.push(MaskComponent { name: None, op: MaskOp::Subtract, invert: false, shape }); + }, + ]; + for (i, edit) in edits.iter().enumerate() { + let mut m = radial.clone(); + edit(&mut m); + assert_ne!(key(&m), key(&radial), "edit {i}"); + } + let mut m = radial.clone(); + (m.id, m.name, m.adjust.exposure, m.adjust.clarity) = (9, "Renamed".into(), 1.0, 30.0); + assert_eq!(key(&m), key(&radial)); + // the frame for every mask; the image and the exposure for the masks that read them + let (other_geo, other_image) = (AlphaKeys { geo: 3, ..keys }, AlphaKeys { image: 4, ..keys }); + let refined = Mask { refine: 40.0, ..radial.clone() }; + for m in [&radial, &lum, &refined] { + assert_ne!(alpha_key(m, other_geo, 0.0), key(m)); + } + assert_eq!((alpha_key(&radial, other_image, 0.0), alpha_key(&radial, keys, 1.0)), (key(&radial), key(&radial))); + assert_ne!(alpha_key(&lum, other_image, 0.0), key(&lum)); + assert_ne!(alpha_key(&lum, keys, 1.0), key(&lum)); + assert_ne!(alpha_key(&refined, other_image, 0.0), key(&refined)); + assert_eq!(alpha_key(&refined, keys, 1.0), key(&refined)); + } + + /// Alphas are only handed back to a render with the segmentation mattes they were evaluated + /// with (another photo's mattes, or none, start empty). + #[test] + fn alpha_cache_is_tied_to_the_mattes() { + let (a, b) = (Arc::new(Mattes::default()), Arc::new(Mattes::default())); + let mut c = AlphaCache::::default(); + c.keep(Some(&a), vec![(1, Arc::new(Plane::new(2, 2)))]); + assert!(c.clone().take(Some(&b)).is_empty()); + assert!(c.clone().take(None).is_empty()); + assert_eq!(c.take(Some(&a)).len(), 1); + assert!(c.is_empty()); + } } diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 2398feec..2cefa11e 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -255,6 +255,188 @@ fn drafts_keep_wide_planes_across_a_noise_reduction_change_and_full_renders_do_n } } +/// [`typical_edits`] plus masks of every kind the alpha cache must tell apart: ones that read +/// only the frame (gradients, a brush), the image (Auto Mask, Subject, Refine Edges) or the image +/// and the exposure (luminance and colour range, Sky). +fn masked_edits() -> DevelopSettings { + use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let comp = |op: MaskOp, shape: MaskShape| MaskComponent { name: None, op, invert: false, shape }; + let mask = |id: u32, components: Vec, refine: f64| Mask { + id, + components, + adjust: LocalAdjustments { exposure: 0.4 * (id as f64 - 3.0), clarity: 20.0, noise: 30.0, ..Default::default() }, + refine, + ..Default::default() + }; + let stroke = |y: f64, auto_mask: bool| BrushStroke { + points: (0..12).map(|k| Point::new(0.1 + k as f64 * 0.06, y)).collect(), + size: 0.05, + auto_mask, + ..Default::default() + }; + let mut s = typical_edits(); + s.masks = vec![ + mask( + 1, + vec![ + comp(MaskOp::Add, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 10.0, feather: 50.0, invert: false }), + comp(MaskOp::Subtract, MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(0.0, 0.4) }), + ], + 0.0, + ), + mask(2, vec![comp(MaskOp::Add, MaskShape::Linear { start: Point::new(0.5, 1.0), end: Point::new(0.5, 0.3) })], 60.0), + mask(3, vec![comp(MaskOp::Add, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 })], 0.0), + mask(4, vec![comp(MaskOp::Add, MaskShape::ColorRange { samples: vec![[0.6, -0.05, -0.08]], refine: 50.0 })], 0.0), + mask(5, vec![comp(MaskOp::Add, MaskShape::Brush { strokes: vec![stroke(0.3, false), stroke(0.7, true)] })], 0.0), + mask(6, vec![comp(MaskOp::Add, MaskShape::Sky), comp(MaskOp::Intersect, MaskShape::Background)], 0.0), + ]; + s +} + +/// A cached render with masks is the same image as an uncached one through any edit, global or +/// to a mask, at either size. +#[test] +fn stage_cache_with_masks_matches_uncached_render_through_a_slider_session() { + use crate::{Quality, StageCache, render_cached}; + use lightcraft_develop::{BrushStroke, MaskShape}; + use lightcraft_geom::Point; + use std::sync::Arc; + let src = Arc::new(scene()); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let full = RenderRequest::fit(200, 200); + let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) }; + let mut s = masked_edits(); + let steps: Vec> = vec![ + Box::new(|_| {}), + Box::new(|s| s.light.exposure = -0.6), + Box::new(|s| s.light.contrast = 30.0), + Box::new(|s| s.masks[0].adjust.exposure = 1.2), + Box::new(|s| s.masks[1].refine = 30.0), + Box::new(|s| s.masks[2].components[0].shape = MaskShape::LuminanceRange { lo: 0.2, hi: 0.6, lo_feather: 0.1, hi_feather: 0.2 }), + Box::new(|s| s.masks[3].adjust.amount = 40.0), + Box::new(|s| s.masks[4].invert = true), + Box::new(|s| { + if let MaskShape::Brush { strokes } = &mut s.masks[4].components[0].shape { + strokes.push(BrushStroke { points: vec![Point::new(0.5, 0.5), Point::new(0.6, 0.55)], erase: true, ..Default::default() }); + } + }), + Box::new(|s| s.masks[5].visible = false), + Box::new(|s| s.masks.swap(0, 1)), + Box::new(|s| { + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = 4200.0; + }), + Box::new(|s| s.effects.clarity = -20.0), + Box::new(|s| s.masks[5].visible = true), + Box::new(|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.05, 0.1, 0.9, 0.85)), + Box::new(|s| s.light.exposure = 0.9), + // (last: a draft then keeps the wide planes of the image before noise reduction) + Box::new(|s| s.detail.nr_luminance = 40.0), + ]; + let nr_step = steps.len() - 1; + for (i, step) in steps.iter().enumerate() { + step(&mut s); + for req in [&full, &draft] { + let a = render_cached(&src, &info, &s, req, &cache).image; + let b = render(&src, &info, &s, req).image; + if i != nr_step || req.quality != Quality::Draft { + assert_eq!(a, b, "step {i} {:?}", req.quality); + } + // and the alphas held are those a cold cache evaluates + let fresh = StageCache::default(); + render_cached(&src, &info, &s, req, &fresh); + let (warm, cold) = (cached_alphas(&cache), cached_alphas(&fresh)); + assert_eq!(warm.len(), cold.len(), "step {i} {:?}", req.quality); + for (k, (a, b)) in warm.iter().zip(&cold).enumerate() { + assert!(**a == **b, "step {i} {:?}: mask {k} is stale", req.quality); + } + } + } +} + +/// The alphas held by the stage cache's most recent entry, in mask order. +fn cached_alphas(cache: &crate::StageCache) -> Vec> { + cache.entries.lock().unwrap().last().unwrap().masks.planes().cloned().collect() +} + +#[test] +fn mask_alphas_are_reused_until_what_they_read_changes() { + use crate::{StageCache, render_cached}; + use lightcraft_develop::MaskShape; + use lightcraft_geom::Point; + use std::sync::Arc; + let src = Arc::new(scene()); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let req = RenderRequest::fit(200, 200); + // radial − linear (reads the frame only), linear with Refine Edges (the image), luminance + // range (the image and the exposure) + let mut s = masked_edits(); + s.masks.truncate(3); + render_cached(&src, &info, &s, &req, &cache); + let step = |s: &DevelopSettings| -> Vec { + let before = cached_alphas(&cache); + render_cached(&src, &info, s, &req, &cache); + let after = cached_alphas(&cache); + assert_eq!(after.len(), before.len()); + before.iter().zip(&after).map(|(a, b)| Arc::ptr_eq(a, b)).collect() + }; + // exposure: only the range mask reads it + s.light.exposure += 0.5; + assert_eq!(step(&s), [true, true, false]); + // a mask's own sliders (except Amount) and global sliders no mask reads + s.masks[0].adjust.exposure = -1.0; + s.masks[2].adjust.clarity = 50.0; + s.effects.clarity = 40.0; + s.light.highlights = 10.0; + assert_eq!(step(&s), [true, true, true]); + // Amount scales the alpha + s.masks[1].adjust.amount = 50.0; + assert_eq!(step(&s), [true, false, true]); + // the image changes (white balance): the masks that read it + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = 4000.0; + assert_eq!(step(&s), [true, false, false]); + // a shape moves: that mask only + s.masks[0].components[0].shape = MaskShape::Radial { center: Point::new(0.6, 0.5), rx: 0.3, ry: 0.2, angle: 10.0, feather: 50.0, invert: false }; + assert_eq!(step(&s), [false, true, true]); + // the frame changes (a flip: same size): all of them + s.crop.flip_h = true; + assert_eq!(step(&s), [false, false, false]); + // a hidden mask's alpha is let go: only the visible masks' are held + s.masks[2].visible = false; + render_cached(&src, &info, &s, &req, &cache); + assert_eq!(cached_alphas(&cache).len(), 2); +} + +/// The memory a stage cache reports covers every buffer it holds, the mask alphas and the blurred +/// chromaticity (local Noise / Moiré) included. +#[test] +fn stage_cache_bytes_count_mask_alphas_and_the_chroma_plane() { + use crate::{StageCache, render_cached}; + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + use std::sync::Arc; + let src = Arc::new(scene()); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let mut s = DevelopSettings::default(); + let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.3, ry: 0.3, angle: 0.0, feather: 50.0, invert: false }; + s.masks.push(Mask { + id: 1, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { noise: 40.0, ..Default::default() }, + ..Default::default() + }); + let img = render_cached(&src, &info, &s, &RenderRequest::fit(200, 200), &cache).image; + let n = img.width * img.height; + // the sampled and linear images (12 B/px each), log luminance and the texture band that local + // Noise reads (4 B/px each), the blurred chromaticity (12 B/px) and the mask's alpha (4 B/px) + assert_eq!(cache.bytes(), (12 + 12 + 4 + 4 + 12 + 4) * n); +} + #[test] fn exposure_after_spatial_filters_equals_exposing_the_source() { // The pipeline filters the image before exposure and applies exposure per pixel; filters on diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 5938c0e8..4dbfa537 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -150,6 +150,14 @@ walks the drive. - Stage cache: `GpuStages` mirrors `StageCache` (same keys: source identity + `geo`, `lin_key`, per-plane radii) and lives in it as an extension, so the UI needs no change. The uploaded source is kept per view. A tone / colour / exposure drag re-runs one dispatch of `finish` plus the readback. + Mask alphas are cached too, on both sides (`masks::AlphaCache`, one plane per visible mask of the + last render), each keyed by `masks::alpha_key`: the mask's components, Refine Edges, Invert and + Amount, `geo`, plus `lin_key` when it reads the image (range masks, Sky / Subject / Background + heuristics, Auto Mask brushes, Refine Edges) and the exposure when it reads that (luminance and + colour range, Sky). An Exposure drag over gradient and brush masks, or a drag of a mask's own + sliders, evaluates no mask; a cached alpha is the buffer a fresh evaluation produced, so warm and + fresh renders stay bit-identical. Both caches' `bytes()` count the alphas and the blurred + chromaticity. - One command encoder per render; the only mid-render readback is the airlight samples (dehaze, when the dark channel is recomputed). Dropped buffers are recycled (exact size, ≤ 2 GB pool) once the thread that dropped them has submitted — allocating and zero-filling fresh 100–300 MB buffers