M5.4c: prefetch filmstrip neighbours; GPU device created at startup off the UI thread

- Once the current photo's loupe render is done, the next and previous photos in filmstrip
  order get a background loupe job (Slot::Prefetch, priority below on-screen thumbnails,
  once per settings): the decoded 2560 px source is kept in the engine's bounded preview
  LRU and the view render is cached, so stepping to a neighbour shows its developed image
  in ~50 ms and then re-renders only on the GPU.
- lightcraft_gpu::warm_up (called when the app is created) builds the device and kernels on
  a background thread; gpu::ready() answers without blocking and ui.inspect uses it, so the
  UI thread never waits for device creation.
- docs/gpu-pipeline.md: opening-a-photo section.

Stepping through 24 MP raws in the loupe (real app): full render 220–580 ms → 45–62 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 09:23:36 -04:00
co-authored by Claude Opus 5.5
parent 5fd5384bfb
commit 570d03e202
6 changed files with 87 additions and 3 deletions
+26 -1
View File
@@ -55,15 +55,40 @@ fn env_disabled() -> bool {
*OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")))
}
#[cfg(not(target_arch = "wasm32"))]
static GPU: std::sync::OnceLock<Option<ctx::Gpu>> = std::sync::OnceLock::new();
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn device() -> Option<&'static ctx::Gpu> {
static GPU: std::sync::OnceLock<Option<ctx::Gpu>> = std::sync::OnceLock::new();
if env_disabled() {
return None;
}
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref()
}
/// Create the device and compile the kernels on a background thread now (app start), so the
/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does.
pub fn warm_up() {
#[cfg(not(target_arch = "wasm32"))]
if !env_disabled() && GPU.get().is_none() {
let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| {
let _ = device();
});
}
}
/// Has device creation finished (successfully or not)? Never blocks — for status displays.
pub fn ready() -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
env_disabled() || GPU.get().is_some()
}
#[cfg(target_arch = "wasm32")]
{
true
}
}
/// Whether a usable GPU adapter exists and GPU rendering is enabled (creates the device on first
/// use).
pub fn available() -> bool {
+1 -1
View File
@@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"activeMask": app.session.active_mask,
"widgetCount": app.widgets.len(),
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()},
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
"status": app.ui.status,
})
+2
View File
@@ -87,6 +87,8 @@ pub struct LightcraftApp {
impl LightcraftApp {
pub fn new(session: Session, services: Services) -> Self {
// GPU device + kernels off the UI thread, before the first photo is opened
lightcraft_engine::gpu::warm_up();
Self {
session,
ui: UiState::default(),
+24
View File
@@ -146,6 +146,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let job = if interacting { job.draft() } else { job };
app.renderer.request(Slot::Main, job, 100);
}
// once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and
// kept, view render cached) so stepping to them is instant
if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) {
let ids = app.session.visible_cloned();
if let Some(i) = ids.iter().position(|p| *p == id) {
let next = ids.get(i + 1).copied();
let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied();
for (n, nid) in [next, prev].into_iter().enumerate() {
let Some(nid) = nid else { continue };
let Some(np) = app.session.catalog.photo(nid).cloned() else { continue };
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
let na = nf.aspect() as f32;
let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan);
let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize;
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
}
}
}
}
// until the loupe has this photo: show its cached render / embedded preview / a thumbnail
if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id)
&& let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool)
@@ -215,6 +236,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
}
/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes.
const PREFETCH_PRIORITY: u32 = 4;
fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
use lightcraft_engine::media::QuickSource;
match q {
+21
View File
@@ -32,6 +32,9 @@ pub enum Slot {
Preview,
Before,
Compare(u8),
/// Background preparation of a neighbouring photo (no texture: its decoded source and view
/// render are cached by the engine). 0 = next, 1 = previous.
Prefetch(u8),
}
pub struct Tex {
@@ -62,6 +65,8 @@ pub struct Renderer {
stages: HashMap<Slot, Arc<StageCache>>,
/// Slot → key of the last quick job requested for it (each is tried once).
quick_tried: HashMap<Slot, u64>,
/// Prefetch slot → key of the last job submitted (each runs once).
prefetched: HashMap<Slot, u64>,
}
impl Default for Renderer {
@@ -76,6 +81,7 @@ impl Default for Renderer {
keep_pixels: false,
stages: HashMap::new(),
quick_tried: HashMap::new(),
prefetched: HashMap::new(),
}
}
}
@@ -114,6 +120,18 @@ impl Renderer {
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
}
/// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one
/// replaces it while queued). Memory stays bounded by the engine's source caches.
pub fn prefetch(&mut self, slot: Slot, job: RenderJob, priority: u32) {
if self.prefetched.get(&slot) == Some(&job.key) {
return;
}
self.prefetched.insert(slot, job.key);
self.pending.insert(slot, (job.key, priority));
let key = job.key;
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
}
/// Is a request for `slot` queued or running?
pub fn is_pending(&self, slot: Slot) -> bool {
self.pending.contains_key(&slot)
@@ -160,6 +178,9 @@ impl Renderer {
let Ok(rendered) = r.rendered else {
continue;
};
if matches!(slot, Slot::Prefetch(_)) {
continue;
}
if let Slot::ThumbQuick(id) = slot {
if self.textures.contains_key(&Slot::Thumb(id)) {
continue; // the real thumbnail won the race
+13 -1
View File
@@ -74,7 +74,19 @@ so CPU numbers are pessimistic; the GPU numbers are less affected):
| export render 6000×4000 | 1037–1414 ms | 291–330 ms |
GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel
compilation: ~0.4 s once per process (lazily, on the first GPU render).
compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch
(`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create
the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking.
## Opening a photo (M5.4)
- The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its
current settings, else (raws with their import look) the embedded camera JPEG, else a
thumbnail-level render; the full render replaces it in place.
- Raw sources for previews (2560 px) and thumbnails are binned straight from the mosaic
(`RawImage::develop_binned`, 2× for a 24 MP preview); only exports / 1:1 demosaic at full size.
- The next and previous photos in filmstrip order are prepared in the background once the current
one is rendered (decoded source kept, view render cached): stepping through photos shows the
developed image in ~50 ms.
## Open items
- Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of