From 2f7d248d61bc58414b1bcfe589c94bc9acf0a345 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:59:53 -0400 Subject: [PATCH] #323: a window does not keep the whole source uploaded with its stages Profiling a slider drag at 100 % and 400 % found each tick of the window render taking about 60 ms: the 24 MP source (288 MB) uploaded to the device was kept with the view's stages, counted against the stage budget, and the stages cleared every frame, so every tick uploaded and resampled again. A source over 96 MB is now uploaded for the render only; the stages keep the window's own sampled pixels. A tick costs 7-12 ms instead. equivalence.rs covers it. Co-Authored-By: Claude Sonnet 5.5 --- crates/gpu/src/render.rs | 9 +++++++-- crates/gpu/tests/equivalence.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index c38418616..46b47f1a4 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -414,6 +414,9 @@ fn warp_params( /// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels). const COVER_ROWS: usize = 16; +/// The largest source (bytes on the device) a view's stages keep uploaded between renders. +const RETAIN_SOURCE_BYTES: usize = 96 << 20; + /// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every /// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose /// interval bounds place them clearly inside or outside the image are filled at once @@ -552,9 +555,11 @@ pub fn render( Some(e) => e.sampled.clone(), None if gpu.fits(src.data.len() * 3) => { let upload = || gpu.upload(rgb_words(src)); + // (a source too big to keep is uploaded for this render only: a window is cut from the + // photo's own pixels, and what its stages keep is its own sampled pixels) let src_buf = match stages { - Some(c) => c.source(src, upload), - None => Arc::new(upload()), + Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload), + _ => Arc::new(upload()), }; sample(&mut cx, src, src_buf, &plan) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index b4b52e215..bfb72e02f 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -701,3 +701,29 @@ fn windows_match() { let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) }; check("window: vignette corner", &src, &info, &s, &corner); } + +/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that +/// upload with the view's stages counted it against the stage budget on every tick of a drag and +/// got the stages cleared, and the upload and resampling redone: the window's stages are its own +/// pixels, not the photo's. +#[test] +fn a_window_does_not_keep_the_whole_source_on_the_device() { + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 6000, 4000); + let info = SourceInfo { raw: true, ..Default::default() }; + let stages = StageCache::default(); + let s = DevelopSettings::default(); + let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render"); + let held = lightcraft_gpu::stage_bytes(&stages); + assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20); + // …and the next tick of a drag reuses what is there + let mut s2 = s.clone(); + s2.light.exposure = 0.5; + let again = lightcraft_gpu::render(&src, &info, &s2, &req, Some(&stages)).expect("gpu render"); + assert_eq!((again.image.width, again.image.height), (640, 480)); + assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20); +}