perf(gpu): #1145 keep mask alphas in GpuStages

The GPU stage cache mirrors the CPU's: per-mask alpha buffers keyed by
masks::alpha_key, copied into the combined alpha buffer on a hit (no
readback for CPU-evaluated shapes). GpuStages::bytes counts them and the
chromaticity buffer it left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chad Elliott
2026-10-11 16:52:12 -05:00
co-authored by Claude Opus 5.5
parent 2d85c4a39d
commit 99b89cd986
3 changed files with 101 additions and 6 deletions
+32 -6
View File
@@ -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<Buf>,
lin: Option<(u64, Arc<Buf>)>,
planes: Planes,
/// Alphas of the last render's masks, as [`lightcraft_pipeline::masks::AlphaCache`] keeps them
/// on the CPU (same keys).
masks: AlphaCache<Buf>,
}
/// 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<Buf>, 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<Buf>,
) -> (Option<Buf>, 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)
}
+61
View File
@@ -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<Box<dyn Fn(&mut DevelopSettings)>> = 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.
+8
View File
@@ -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