mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 13:38:14 -06:00
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:
co-authored by
Claude Opus 5.5
parent
5fd5384bfb
commit
570d03e202
+26
-1
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user