* GPU-FULL-1: LUT profiles, Refine Edges and soft proofs render on the GPU These three sent the whole loupe / compare / export render back to the CPU pipeline (media::develop and lightcraft_gpu::render declined them). - LUT profiles: the .cube table rides in the finish kernel's aux buffer (LUT_* parameters); lut_apply mirrors Lut3d::apply (trilinear, domain), applied after grain and blended by the profile amount, as on the CPU. - Refine Edges: the cross guided filter Auto Mask already ran on the device, now with a combine mode (max for strokes, mix by sqrt(refine) for Refine), applied to the combined alpha before the mask's invert and amount. - Soft proofing: proof-space gamut mapping and the destination / display gamut warnings in the kernel (PROOF_* parameters, GAMUT_WARN and warning colours generated from the CPU constants), for output spaces and displays. Equivalence tests (RTX 5070, DX12): LUT profiles at three amounts and a non-default domain, Refine 15/60/100 on two masks, proofs in 16 combinations of space, output, display and warnings - all max 1 LSB, mean <= 0.0005 LSB. The tests also check that strong refine and the destination warning change the image (so they aren't comparing two unchanged renders). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-2: spot removal, defringe and the histogram on the GPU Spots and defringe made every render that had them (and every white balance or spot edit) read the resampled image back, run the scene-linear stage on the CPU and upload it again. Now only the ProRAW local tone map (off by default) does. - Defringe: OkLab pass, separable lightness min/max filter, per-pixel apply. Parameters come from the new optics::defringe_params, which the CPU stage now uses too. - Spots: new spot module (spot_prep / spot_apply over each spot's box, the Heal tone correction as device Gaussians), spot by spot in place so each reads what the earlier ones left, as on the CPU. Remove's automatic source: spot_score rates the 48 candidates on the device and only the scores come back. Layout, candidates, spoke directions, radii and ranking are shared helpers in pipeline::spots (spots::apply uses them; behaviour unchanged, pipeline tests green). - Histogram: counted with atomics on the device on Histogram::of_srgb8's sampling grid (exact), instead of a CPU pass over the readback. Tests: spots (clone, heal, auto remove incl. near the edge where no candidate fits, brushed strokes, feather 0, cut by the edge, overlapping) and defringe (purple/green, hue ranges, preview and full size) max 1 LSB; the device histogram equals the CPU count of the returned image for small, sampled (>1 MP) and windowed renders; the scoring kernel picks the CPU's candidate. (The edge case caught a real bug: "doesn't fit" was written as a decimal literal that rounds below f32::MAX.) Unit tests that create the device now take turns with the crash-sentinel test, which swaps a global. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-2: fix the spot kernels' build; GPU tests fail when kernels don't build 07d4680b's spot module declared `const F32_MAX = bitcast<f32>(...)`, which naga can't evaluate as a constant: the kernels failed to build, the device was unavailable, and the equivalence tests skipped as if there were no adapter - so that commit's GPU tests didn't actually run. f32::MAX is now a function, and the equivalence suite (and the device unit test) fail instead of skipping when an adapter exists but the kernels don't build. Re-verified on the RTX 5070 (DX12), all on the device: 21 equivalence tests incl. the 8 spot cases (max 1 LSB), defringe, the exact device histogram, and the auto-source unit test. Adds an ignored thumbnail throughput bench (tests/thumbs_bench.rs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-3: thumbnails render on the GPU once the device is ready Every cached job (grid and filmstrip thumbnails, preset and profile previews, faces, quick stand-ins) was forced onto the CPU. They now use the GPU when lightcraft_gpu::ready() - so they never block on device creation at startup (1.8 s incl. kernels here) and render on the CPU until then. Views and exports keep waiting for the device as before. tests/thumbs_bench.rs (ignored): six workers rendering 256 px thumbnails of 512 px sources with preset-like settings (NR, sharpening, clarity, grading, a gradient mask): RTX 5070 3.6 ms each vs 4.3-5.2 ms on 32 CPU threads, with the cores left free for raw decoding. Engine and UI suites: unchanged (only the 12 failures that predate this branch on Windows: tests_sync paths, tests_model_setup). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-4: the loupe's drafts are drawn straight from the GPU Rendering and drawing now share one device: the desktop app creates the window's wgpu device with the adapter's limits and hands it to the GPU renderer (lightcraft_gpu::adopt_window_device, on a background thread at startup, under the crash sentinel; a device that doesn't suit falls back to one of the renderer's own, as before). A drag's drafts of the views are RenderJob::on_screen: the render ends in an Rgba8Unorm texture (lightcraft_gpu::render_display -> Output::Display, new `present` kernels: unwritten-pixel count, row padding for the copy) and egui-wgpu draws it as a native texture (ui-egui ScreenTextures, implemented by the app) - no readback, no conversion to egui pixels, no re-upload. The readback's checks run on the device (atomic unwritten count, device histogram). Renders that need pixels keep the readback: the settled full render (view cache, overlays, screenshots), thumbnails, exports, overlays, windows that spots grew, a monitor profile. - engine: RenderJob::on_screen, RenderResult::frame (ScreenFrame). - ui-egui: Tex::native / Tex::id() (21 draw sites), Renderer set_screen_textures, natives per slot (freed when pixels return), ui.inspect perf.gpuWindowDevice / screenTextures / mainOnGpu. - app: WindowTextures, window device limits, LIGHTCRAFT_GPU_WINDOW=0 to opt out. Tests: crates/gpu/tests/display.rs adopts a device in its own process and checks the texture holds exactly the readback's pixels (padded and unpadded widths, draft and full), the histogram matches, and overlays / grown windows come back as pixels. In the app (RTX 5070, a 45 MP CR3 from the user's library copy): all 90 drafts of a drag drawn from GPU textures, 1.69 ms vs 2.05 ms median per draft render with the readback; the frame screenshot shows the image correctly; pixels return after the drag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-2: the denoise runner tests take turns with the crash-sentinel tests Building a denoise runner creates a device and compiles kernels under the init marker. Running in parallel with backend::tests, it could arm the marker the test had just set, so "is removed afterwards" failed now and then. The runner tests now hold DEVICE_TEST_LOCK while they build, like the render tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-4: fix the build after merging upstream main Upstream's QuickJob panic guard builds a RenderResult (add `frame: None`), its idle-trim test builds a Tex (add `native: None`), and its on_exit test builds App (add the WindowTextures slot). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL-2: upstream's blur tests take turns with the crash-sentinel tests gaussian_matches_cpu and bench_kernels create the device, so they hold DEVICE_TEST_LOCK like every other device test in lightcraft-gpu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tests: the same-folder export test compares paths separator-blind (Windows) The written path is the original's folder (`\` on Windows) joined with `Exports/IMG_1.jpg`; the expected one had `\Exports`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tests: the folder-drop tests find folder widgets separator-blind (Windows) The folder tree spells its paths with `/`; the tests built widget ids from Windows paths with `\`, so none was found and every test failed on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tests: the Windows path fixes take the same form as in the other PR branches So the branches merge into main without conflicts between their copies of the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: headless screenshots read back GPU-drawn views; LIGHTCRAFT_GPU_LIMITS caps the adopted device Review of PR #1065: - A headless `ui.screenshot` taken mid-drag showed the photo as a white rectangle: the GPU-resident draft left no CPU copy. With keep_pixels (control channel) the renderer now keeps each view's frame, and `cpu_textures` reads it back through `ScreenTextures::read` (`lightcraft_gpu::read_texture`). Drawing still never reads back, so drags keep the speed-up. - Adopting the window's device skipped LIGHTCRAFT_GPU_LIMITS; `Gpu::adopt` now caps its limits the same way `Gpu::new` does. Tests: a_headless_screenshot_reads_back_a_view_drawn_from_the_gpu (ui-egui), the_limits_override_caps_an_adopted_device (gpu); the display test uses read_texture and skips through lightcraft_testkit::skip!. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: review fixes (CodeRabbit 2026-10-21) and slideshow/print draw GPU-drawn views - the window-device adoption is announced before its thread starts: until it finishes, the settings' warm-up and renders don't create a device of their own (they raced it) - the device error / device-lost handlers go on only after the kernels build, so a declined window device keeps the window's own handler - releasing a slot frees its GPU-resident texture and kept frame - a soft proof into a colour space drafts full size on the GPU; only one to a device profile counts as proofed for `handles` - docs: Fill spots and device-profile soft proofs render on the CPU - slideshow, slide layout and print draw `Tex::id()` (the GPU texture when there is one) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: the display test skips when LIGHTCRAFT_GPU=0 switches the GPU off It panicked ("GPU rendering is off") instead of skipping like the other GPU tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: a view readback never panics on a lost device read_texture (headless screenshots of GPU-drawn views) polled the device directly. On a lost device wgpu 30 panics in Device::poll; it now goes through the same guard as the renderer's own polls (poll_device): a lost device returns an error, stops the GPU and is not polled again. Review of PR #1065. Also quiets a browser-build dead-code warning on OnScreen::Frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: an HDR edit's histogram shows the HDR range on the on-screen GPU path too develop_on_screen returned the GPU's SDR histogram for both its Display and Image arms; it now applies lightcraft_pipeline::hdr_histogram as develop_reporting does (None for drafts, windows, proofs and non-HDR edits, which keep the GPU's histogram). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: the Navigator draws the photo's texture as it is drawn in the loupe The Navigator falls back to the main view's texture before the photo has a thumbnail. It drew `t.tex`, which is stale (or the 1x1 placeholder) when the main view holds a GPU-resident draft; it now draws `Tex::id()`, like every other draw site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: the egui 0.37 lifecycle test's texture names no native id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: rustfmt the lifecycle test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: the second window fits the frame's size; freed GPU frames drop their views The second window sized its image from the texture handle, which is only a 1x1 placeholder for a GPU-resident render; it now uses the frame's size. When a GPU-resident render can't be shown and every native texture is freed, the views pointing at them (Before, Region, Compare tiles) are dropped too, so they render again as pixels instead of drawing a freed id. Test: freeing_gpu_frames_drops_the_views_drawn_from_them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(gpu): LIGHTCRAFT_GPU_ADAPTER picks the card for rendering too The variable chose the card only for AI Denoise and the face detector (two copies of the same enumerate-and-match code); photo rendering always took the first high-performance adapter, on a two-card machine the one driving the screens. One helper, backend::pick_adapter, now serves the render device (Gpu::new), the denoise network and the face detector: the first hardware card whose name contains the value (any case), else the high-performance one. A name that matches no card is logged and falls back instead of failing. backend::may_adopt_window_device says whether rendering may share the window's device (only for the window's card). Name matching is unit-tested without a GPU; the equivalence tests print the card they ran on. Documented in docs/gpu-pipeline.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: a view switching to a GPU texture releases its pixel image Review of #1065 (CodeRabbit nitpick): when a slot that had pixels switched to a GPU-resident texture, its egui texture kept the full-size image, never drawn while the native texture is set. Tex::show_native now swaps it for a 1x1 placeholder on the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(gpu): LIGHTCRAFT_GPU_ADAPTER picks among several matches in a defined order and logs once Review of #3428: - several cards match: the one whose whole name it is, then dedicated before built-in before virtual cards, then wgpu's order (backend::choose_named_adapter); the log names every match and the one used; - no match: the default choice, with a warning that lists the cards; - a name matching only a software renderer: never used, and the warning says so; - each line is logged once (backend::log_once), though the render device, AI Denoise and the face models each ask; - tests on fake adapter lists: a_fake_adapter_list_picks_in_a_defined_order, a_choice_is_logged_once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: the release note gives the GPU's documented bound against the CPU (3 levels, mean under half a level) Review of PR #3544. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU-FULL: the window's device isn't adopted when LIGHTCRAFT_GPU_ADAPTER names another card With #3428 merged, Gpu::new already makes the render device on the named card; the window's device was still adopted first, so the app rendered on the screen's card whatever the variable said. backend::may_adopt_window_device (tested on names in backend.rs) now decides: another card named renders on a device of its own, and the window stays on the screen's card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
37 KiB
GPU develop pipeline (M5.2)
The CPU pipeline (crates/pipeline) is the reference ("oracle"). lightcraft-gpu (crates/gpu, L3)
evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is
pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in.
CPU fast guided-filter allocation measurements and reproduction commands are documented in guided-filter-memory.md. That optimization retains the full-size base and does not change the GPU's coefficient buffers.
Warped geometry uses a CPU-computed coverage bit mask for the source boundary.
The CPU reference evaluates that boundary in double precision; GPU float
rounding otherwise can turn a blank edge pixel into a photo pixel. The mask
uses one bit per output pixel (rows padded to 32 bits). Sampling and color
corrections remain on the GPU, and existing geometry stage caching reuses the
result. The mask is built per 32 × 16 block (Warp::block_coverage): the warp
formulas are written once, generic over lightcraft_geom::Real, and evaluated
with outward-rounded Intervals over the block. Those bounds enclose the f64
result of every pixel in the block, so a block whose bounds lie inside the
image edges (the same f64 comparisons, no margin), or beyond one of them, is
filled at once; only blocks across the edge, or whose bounds are undecided (a
perspective denominator that may vanish), evaluate each pixel (Warp::covers,
the framing chain Warp::frame the CPU resample uses too). The bits equal the
per-pixel decision. At 6000 × 4000 with lens warp + perspective the mask takes
~2.7 ms (32 threads; ~24.5 ms on one) instead of ~30 ms (~580 ms), and only
when warped geometry rebuilds.
Backends, environment variables and troubleshooting (issue #136)
wgpu loads the driver of every backend in an instance's set while it enumerates adapters — even
when it then picks another one. A Vulkan driver that crashes there (issue #136: an access violation
in Intel's igvk64.dll on a UHD 630 under Windows 11) takes the process down before any window
appears, and a native crash cannot be caught. So LightCraft only lets wgpu touch the backends it
means to use (lightcraft_gpu::backend), for its compute device and for the desktop window
(eframe/egui-wgpu, window_wgpu_options in apps/lightcraft/src/main.rs):
| platform | default (window and compute) |
|---|---|
| Windows | DX12 only — the Vulkan driver is never loaded unless asked for |
| macOS | Metal |
| Linux / BSD | Vulkan (the window also lists GL, eframe's default; no GL backend is compiled in) |
Overrides (read once at launch):
LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | gl | auto | off(comma lists allowed, e.g.vulkan,dx12): the backends for both the window and GPU rendering;offturns GPU rendering off (CPU pipeline) — the window still needs a backend and keeps the platform default. A backend this build doesn't contain (gl) is ignored with a warning.- else
WGPU_BACKEND(wgpu's own variable, same names) — before #136 only the window honoured it, the compute device always used Vulkan + DX12 (+ Metal). LIGHTCRAFT_GPU=0: GPU rendering off for the process (the window is unaffected).- Both
LIGHTCRAFT_GPU=0andLIGHTCRAFT_GPU_BACKEND=offalso keep AI Denoise, the face models and the AI mask model (SAM 3 on Metal) on the CPU. LIGHTCRAFT_GPU_ADAPTER=<part of a card's name>(any case, e.g.4070,radeon,"rtx 4070 super"): the graphics card for GPU rendering, AI Denoise and the face models, on a machine with more than one (backend::pick_adapter). Of the hardware cards whose name contains it, the one whose whole name it is wins, then dedicated before built-in before virtual cards, then wgpu's order (backend::choose_named_adapter); the log says which when several match (… matches 2 graphics cards (…): using …). Software renderers (WARP, llvmpipe) never: a name that matches only one is logged as such. A name that matches no card is logged (gpu: LIGHTCRAFT_GPU_ADAPTER=… names none of the graphics cards (…); using the default choice) and the default high-performance card is used. Each line is logged once, though the render device, AI Denoise and the face models each ask. Each of those makes its own wgpu instance and device, so devices on different cards share no buffer, pipeline or queue. The window stays on the card that drives the screen, and rendering runs on a device of its own on the chosen card (the photo is copied to the screen's card); a build that lets rendering share the window's device does so only when the variable names the window's card (backend::may_adopt_window_device). The log'sgpu: LIGHTCRAFT_GPU_ADAPTER=…: using …line names the card used. Handy to keep test runs or a long export off the card the screens hang on.LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1: accept a software adapter (llvmpipe / lavapipe, WARP), which is otherwise refused because the CPU pipeline is faster. For running the GPU tests on a machine without a GPU (below); the app never sets it.
DX12 shader compiler (issue #471). Every instance (window and compute) compiles DX12 shaders with
FXC (d3dcompiler_47.dll, part of Windows; lightcraft_gpu::backend::backend_options). wgpu's
default, Auto, loads whichever dxcompiler.dll the DLL search path finds first. LightCraft ships
none, so that copy belongs to another program (a Windows SDK, a folder on PATH). An older copy without
dxil.dll beside it (e.g. DXC 1.7) warns that the DXIL is unsigned, wgpu treats the warning as a
compile error, its indirect-validation pipelines fail, the device is lost, and the window never opened
(WGPU error: Parent device is lost). WGPU_DX12_COMPILER=dxc | auto | fxc (wgpu's variable) still
chooses; with dxc, put dxcompiler.dll and dxil.dll (DXC ≥ 1.8.2502) next to lightcraft.exe.
FXC builds the compute kernels somewhat slower than DXC (≈ 9 s vs 7.5 s for the GPU test suite on an
RTX 4080), off the startup path.
FXC merges stores to neighbouring elements of a storage array into one wider store. NVIDIA's DX12 driver ran
the 16-byte store it made of a denoise convolution's output wrong on a GTX 1070 (every second pixel's channels
shifted; DXC and Vulkan were right), so nn_conv.wgsl steps those stores by 1 + zero from its uniform and FXC
leaves them apart. The transposed convolution's merged store ran right on that card, but it steps the same way.
nn::tests::a_layer_stores_every_pixels_channels_where_the_next_reads_them catches the fault on such a card;
a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them checks the other store exactly.
Off the startup path. The compute device is created on a background thread once the window is
up (gpu::warm_up from the first frame's settings), and never while GPU rendering is off: with
Settings ▸ Performance ▸ Use the GPU for rendering unchecked (applied before the window opens),
LIGHTCRAFT_GPU=0 or LIGHTCRAFT_GPU_BACKEND=off, no GPU driver is loaded for rendering at all.
Quitting. The devices are never dropped, and renders run on worker threads: a render still inside
the driver while the process exits crashes there (a SIGSEGV in the NVIDIA Vulkan driver on Linux,
issue #620), which no panic hook catches. So every entry into a device counts as work in flight
(crates/gpu/src/exit.rs), and the desktop app ends with LightcraftApp::shutdown (from on_exit,
after settings and library are saved): gpu::begin_shutdown() — from then on gpu::render returns
None, no device is created, denoise tiles fail over and a render job gives up instead of rendering
on the CPU — then the render pool stops (queued jobs dropped, running ones waited for) and
gpu::wait_idle waits for GPU work on other threads (export, denoise, the device warm-up). One
deadline of 2 s covers it all; past it the app quits anyway. A job pool that is dropped (headless
hosts, tests) waits for its running jobs the same way (JobPool::shutdown, crates/preview/src/pool.rs).
Crash sentinel. The desktop app writes gpu-init.marker into its settings folder (next to
ui.json: %APPDATA%\LightCraft, ~/Library/Application Support/LightCraft,
~/.config/lightcraft) just before the compute device is created and its kernels are compiled (and, on the first
AI denoise, before that network's kernels are) and removes it as soon as that returns, successfully or
not (issue #250: Apple's Metal compiler, MTLCompilerService, aborting on a kernel on an older Intel
Mac). A kernel the driver refuses without crashing is caught by wgpu error scopes: the log says why
(gpu: rendering on the CPU: …) and renders stay on the CPU. LIGHTCRAFT_GPU=0 (or Settings ▸
uncheck Use the GPU for rendering) forces the CPU path from the start. If the marker is still there at the next launch, the process died inside
the driver: LightCraft starts with GPU rendering off (the preference is saved unchecked), removes the
marker and says so in a notice. Checking Use the GPU for rendering again tries the GPU once more
(and re-arms the sentinel). Not with LIGHTCRAFT_NO_PREFS (tests, scripts), and not in a --memory session,
which writes nothing: it neither arms the sentinel nor removes a marker it finds (it still starts with GPU
rendering off when one is there, and the next ordinary launch reports and clears it). Killing the app during
the ~0.3 s of device creation, or two instances starting at the same moment, can trip it falsely —
harmless: rendering is then on the CPU until the box is checked again. The sentinel covers the compute
device and every kernel build; a crash while the window's renderer starts is avoided by the backend defaults above
or worked around with LIGHTCRAFT_GPU_BACKEND.
The window's renderer and its fallback (Windows). Before the window opens, each renderer is probed in
order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's
pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is
incorrect. (0x80070057)" in egui_pipeline) is logged and skipped instead of killing the app; no pop-up
unless that renderer was forced (#1624). The renderer
that worked is remembered in the settings folder (window-renderer); a start that dies before the window is up
leaves window-init.marker, and the next start (relaunched automatically, at most 4 times) tries that
renderer last. LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow (or WGPU_BACKEND) forces one; DX12 stays
first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own
device and falls back to the CPU as before.
The window's surface: one frame in flight. The window's wgpu options start from eframe's defaults
(NativeOptions::default().wgpu_options, window_gpu::base_options), whose surface is egui-wgpu's
SurfaceConfig::LOW_LATENCY: vsync, at most one frame queued ahead of the display (two drawables on
Metal). egui-wgpu's bare WgpuConfiguration::default() is HIGH_THROUGHPUT (two frames queued, three
drawables), and the window used it from #136 until this change, so a drag reached the screen one frame
(8.3 ms at 120 Hz, 16.7 ms at 60 Hz) later. The window's own GPU work is small (egui's meshes and
texture uploads; renders run on the compute device), which is what LOW_LATENCY is meant for.
During a live window resize egui-wgpu still uses three drawables on macOS.
Troubleshooting a crash at startup (Windows). Start LightCraft from a .cmd file or a terminal
with set LIGHTCRAFT_GPU_BACKEND=dx12 (the default since #136), or =off to keep the GPU out of
rendering; set VK_LOADER_DRIVERS_DISABLE=*igvk64* (Vulkan loader) hides a specific Vulkan driver
from every program started with it. Help ▸ System Info and app.gpu show the adapter and backend in
use (e.g. Intel(R) UHD Graphics 630 (Dx12)).
LightCraft stops during an export or a render; the log says "device lost" (Windows, issue #1337).
When the graphics driver resets the GPU (the Windows watchdog, a driver hang: DXGI 0x887A0005, "GPU
device instance has been suspended"), renders fall back to the CPU, but the window's renderer (egui-wgpu)
panics on the lost device and LightCraft stops. It says so ("LightCraft stopped unexpectedly", not "could
not open its window") and records it in gpu-lost.marker in the settings folder: the next start turns
Use the GPU for rendering off and says why once. Turn it back on in Settings ▸ Performance after a
driver update.
Window never appears, "not responding" (Windows, NVIDIA; issue #374). With Shader Cache Size
set to Disabled in NVIDIA Control Panel (Manage 3D settings → Global Settings), NVIDIA's driver
walks the whole system drive while the window's GPU device is created (D3D12CreateDevice →
nvwgf2umx.dll → NvMemMapStoragex.dll!TotalDiskUsage), before the first frame: a black,
unresponsive window for minutes. The Vulkan driver shares that code, so another backend doesn't
help. Set Shader Cache Size back to Driver Default (or any size); the setting is global, there
is no per-program override. Other programs that create a DX12 or Vulkan device at startup hang the
same way (PhotoCraft does). It is a driver issue: nothing in LightCraft itself
walks the drive.
Where it is used
lightcraft_engine::media::develop(called by everyRenderJob): loupe / before / compare views,render_now(CLI, MCP, control channel renders) and exports render on the GPU when one is available. Thumbnails (grid, filmstrip, preset and profile previews, faces) render on the GPU once the device is ready (lightcraft_gpu::ready()) and on the CPU until then, so they never wait for device creation at startup. Six workers rendering 256 px thumbnails: 3.6 ms each on an RTX 5070 vs 4.3–5.2 ms on 32 CPU threads (crates/gpu/tests/it/thumbs_bench.rs), and the cores stay free for raw decoding.- 8-bit renders take the GPU path with LUT profiles (the
.cubetable rides in the kernel'sauxbuffer, trilinear likeLut3d::apply, after grain), masks with Refine Edges (the cross guided filter of Auto Mask, blended by √refine before the mask's invert and amount) and soft proofs into a colour space (proof-space gamut mapping and both gamut warnings, into an output space or a display). These render on the CPU: high-bit-depth exports and Visualize HDR (both need float output), soft proofs to an output device's profile (a printer or paper profile), and photos with a content-aware Fill spot (no GPU kernel for it). - Anything the GPU path cannot do returns
Noneand the CPU renders instead: no adapter (CI machines, software-only adapters),LIGHTCRAFT_GPU=0orLIGHTCRAFT_GPU_BACKEND=off(whole process; see Backends), theapp.gpu {enabled}command (runtime preference;ui.inspect→perf.gpushows the adapter), a buffer larger than the device's storage-buffer limit, or a render the device did not complete correctly (see Limits, failures and the CPU fallback). - wasm32: the crate compiles to the CPU fallback (WebGPU needs asynchronous device creation and a device shared with the canvas — open item below).
Structure
- Shared parameters, so the two implementations cannot drift apart:
pipeline::plan(effective settings, frame, output size, stage-cache keys),Frame::sample_plan,local::{wb_matrix_for, nr_params, plane_sigmas, guided_fast_step, airlight_of},finish::{FinishParams, mask_terms},masks::brush_dabs; exact tables (tone LUT, sRGB LUT, curve LUTs, resample taps) and the OkLab matrices are uploaded / generated into the WGSL prelude from the CPU values. - Buffers, not textures: images are
array<f32>with the CPU's interleaved layout (RGB, 1–3 channels), so upload / readback are plain copies ofRgb32f/Plane/Rgba8data. - Kernels (
crates/gpu/src/wgsl/):geom(orientation pixel map, bilinear through the crop/straighten/flip affine or the full lens + perspective warp),resize(separable resample with the CPU's taps; Mitchell prefilter, box/bilinear for the fast guided filter),blur(box passes, three each way = the CPU's Gaussian: horizontal ones sum each pixel's window, so adjacent threads read adjacent pixels; vertical ones slide running sums down chunks of a column),map(log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight sampling),mask(linear / radial / luminance / colour range / brush shapes, combine, finalize),finish(the whole per-pixel stage) — each mirrors a named CPU function. - Defringe (
fringe_lab, a separable lightnessminmaxfilter,fringe_apply; parameters fromoptics::defringe_params) and spot removal (spotmodule, spot by spot in place:spot_prep, the Heal tone correction as two device Gaussians,spot_apply; Remove's 48 candidate sources are scored byspot_scoreand only the scores are read back; layout and candidates fromspots::{layout, candidates, ring_dirs, score_radii, rank}) run on the device inlin_cpu's order. The histogram is counted on the device (hist, atomics, the same sampling grid asHistogram::of_srgb8, so it is exact). - Soft proofing against an output space (
RenderRequest::proof, with its destination and display gamut warnings) runs infinish; a proof against an output-device profile (its 3D table) renders on the CPU. - Per-stage hybrid: the camera's local tone mapping (ProRAW gain tables, off by default) runs the scene-linear stage on the CPU and uploads; heuristic mask shapes (Sky, Subject, Background, …) and brushes of more than 4096 dabs are evaluated on the CPU and uploaded; sources over the buffer limit are resampled on the CPU.
- Stage cache:
GpuStagesmirrorsStageCache(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 offinishplus the readback. - 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 per pass cost as much as the passes.
LIGHTCRAFT_PROFILE=1prints GPU stage timings (each stage is then submitted and waited for).
Limits, failures and the CPU fallback
Issue #78: on an Intel UHD (ICL GT1) iGPU with Mesa/Vulkan, exports from the desktop app came out
as valid JPEGs that were entirely black, while previews, lightcraft-cli render and
LIGHTCRAFT_GPU=0 were fine — and the GPU export took ~15 s against ~3 s on the CPU. The readback
returned a buffer the per-pixel stage had never written, with no error. The likeliest cause: the
whole export was recorded as one command submission lasting seconds on that GPU, and the driver
reset it (GPU hang check / preemption timeout — the app's own window keeps the GPU busy, the CLI
doesn't) without the error reaching wgpu. wgpu also reports a lost device only through the
device-lost callback, which was not set. So a GPU render now refuses to hand back anything it
cannot vouch for:
- Short submissions. A render submits after every stage and every 16 M kernel invocations
(
FLUSH_INVOCATIONS); the per-pixel stage runs in bands of ~4 MP. Every submission on a slow iGPU stays short enough to be preempted and never trips a watchdog (Windows TDR ~2 s, i915). Buffers released by earlier stages are reused by later ones, lowering peak device memory. - Error scopes. Each render runs inside out-of-memory, validation and internal error scopes (thread-local in wgpu): errors fail that render instead of disappearing into the uncaptured handler. The device-lost callback and the uncaptured handler (other threads) stop GPU use.
- Readback. The wait has a timeout (60 s); a failed wait, map error or missing map callback fails the render (before, a lost device left the readback waiting forever).
- Limits. The device is created with the adapter's limits; every buffer is checked against
min(max_storage_buffer_binding_size, max_buffer_size)when it is created. A render with a buffer over the limit (24 MP RGB f32 = 275 MiB; a 128 MiB WebGPU-default device; many masks) falls back for that render, without a device error. Dispatches stay under 65535 workgroups per dimension (groups1folds 1-D kernels onto two axes). Exports over the limit are not tiled: most stages need neighbourhoods tens to hundreds of pixels wide (guided filters at clarity / base radius, dehaze dark channel) and some need global values (airlight percentile), so tiles would need wide overlaps and a two-pass plan — the CPU renders those exports instead. - Sanity checks on the result. The output buffer is cleared before the per-pixel stage, which
writes alpha 255 everywhere: any pixel without it means work did not run, and the GPU is not used
again (
incomplete image). An entirely black result (n ≥ 4096 pixels) is re-rendered on the CPU for that render only — a really black photo costs one extra CPU render, nothing else; there are no false positives because the CPU result is what is returned.
| failure | this render | later renders |
|---|---|---|
| buffer over the device limit | CPU | GPU |
| out of device memory | CPU (buffer pool emptied) | GPU |
| entirely black result | CPU | GPU |
| validation / internal error, device lost, readback failure or timeout, incomplete image, panic | CPU | CPU (whole process) |
Diagnosing. ui.inspect → perf.gpu (adapter in use), perf.gpuReason (why renders don't use
the GPU, e.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe (LLVM 21.1.8, 256 bits)) skipped: …", "stopped after a GPU failure: device lost …"), perf.gpuFallback (the latest render
redone on the CPU and why, e.g. "6000×4000: the GPU returned an incomplete image (24000000 of 24000000 pixels unwritten); the GPU is not used again"). The same in app.gpu (reason,
lastFallback), Help ▸ System Info and Settings ▸ Performance. LIGHTCRAFT_PROFILE=1 prints the
fallback with the stage timings; RUST_LOG=warn logs it.
perf.gpuFallback is one value for the whole process, overwritten by the latest render on any
thread. A render's own reason comes from lightcraft_gpu::try_render (Err(Fallback::Failed(why));
Fallback::NotTried when the GPU was not used at all), which lightcraft_engine::media::develop_reporting
passes on as RenderResult::gpu_fallback; an export carries it as Exported::gpu_fallback, and
each file of an app.export batch result has gpuFallback when its render fell back (issue #675).
Reproducing a smaller GPU: LIGHTCRAFT_GPU_LIMITS=webgpu (or downlevel) creates the device
with 128 MiB storage bindings / 256 MiB buffers, LIGHTCRAFT_GPU_LIMITS=<n> with n MiB / 2n MiB;
the window's device, when adopted, is capped the same way.
Tests inject failures with lightcraft_gpu::inject_fault (crates/gpu/tests/it/fallback.rs,
export_falls_back_to_the_cpu_when_gpu_work_is_lost). The fault is thread-local, and so are its
consequences: a simulated fatal failure stops GPU use on the injecting thread only (available()
false, unavailable_reason() set there), until reset_failures(), which undoes nothing else — a
real failure still stops the whole process. Such a test asserts on its own render's reason, not on
last_fallback(), so it can run beside other tests rendering on the GPU (issue #675).
Correctness: CPU oracle and equivalence tests
crates/gpu/tests/it/equivalence.rs renders the same settings on both and compares 8-bit sRGB:
bounds mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB per channel. Measured (Apple M4 Pro, Metal):
max 1 LSB, mean ≤ 0.0003 LSB for every case — 26 settings cases (all tone/colour/effects tools,
3 vignette styles, grain, curves, grading, B&W, WB, NR, crop/straighten/flip, lens + perspective,
three mask sets incl. CPU-evaluated shapes, spots + defringe), all 8 orientations ± crop, embedded
DNG lens data, display-referred sources, draft quality, full-size renders; the 24 MP export in
render_bench also differs by max 1 LSB. Cached (slider-drag) GPU renders are bit-identical to
fresh ones. windows_match runs the settings cases again as a window of a 3000 × 2000 frame
(RenderRequest::window, the zoomed loupe, issue #323) plus a vignette in a corner and a spot reading
from outside the window: the vignette geometry arrives as the VIG_VIEW parameter, the airlight and
noise-reduction size come from the whole frame, and a window that spots or Auto Mask strokes grew is
cut back after the readback. The tests skip (pass with a note) when no adapter exists.
AMD Radeon 8060S (RADV STRIX_HALO, Vulkan 1.4, Mesa 26.2.4, Linux 7.2), 2026-10-12, the first
non-Apple GPU verified: all 16 tests pass, 135 compared cases. 124 differ by max 1 LSB, 10 are
bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% of pixels. Worst
mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds
(cargo test -p lightcraft-gpu --release --test it equivalence:: -- --nocapture).
Running them without a GPU (issue #1050). Gpu::new turns down software adapters, so on a
machine without a hardware GPU (a VM, a container) every GPU test skips.
LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 lets them through for one run (tests only: the app never sets it):
log="$(mktemp -d)/gpu-skips.jsonl" # a new private directory; an absolute path, as tests run in crates/gpu
LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG="$log" cargo test -p lightcraft-gpu --tests
grep '"need":"gpu"' "$log" | grep -v -e 'lost on purpose' -e 'memory never ran out' # no output: only the two below skipped
- Linux: Mesa's lavapipe (Vulkan;
mesa-vulkan-driverson Debian and Ubuntu). Its storage bindings stop at 128 MiB (the WebGPU default), so the two big-source tests use a 117 MiB source: above what a view keeps with its stages (96 MiB) and small enough to be uploaded there. - Windows: WARP, the DX12 software rasterizer that ships with Windows (shaders through FXC, as above).
- macOS: Metal is a hardware adapter, so the tests run without the variable.
A machine with a hardware GPU picks it over the software adapter, variable or not. Each test that
skips prints skipped (gpu): … and appends a line to the LIGHTCRAFT_SKIP_LOG file; with a software
adapter the only ones left should be the device-loss test (no GPU device, or one that can't be lost on purpose here) and the device-memory test (no GPU device, or its memory never ran out: on lavapipe its
allocations never fail, seen 2026-10-31). Measured 2026-10-11 on lavapipe and WARP, and again 2026-10-30 on lavapipe (Mesa's
llvmpipe on LLVM 20, 143 compared renders), every compared render stayed within max 1 LSB, mean ≤
0.0003 LSB, as on the M4 Pro. The equivalence binary takes about 20 s on lavapipe and several
minutes on WARP.
Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on Metal and summation order in the box filters — all far below one 8-bit step.
Measurements
In the app: Help ▸ Debug ▸ Frame Rate (view.frameRate, {show?}; LIGHTCRAFT_FPS=1
starts with it on, never saved) draws frames per second, each frame's UI CPU time against its
time spent waiting (for vsync, or for the GPU), the latest whole-frame and zoom-window render
times, the render queue, renders thrown away as superseded, the GPU adapter and its memory, and a
graph of the last frames with a tick where a render arrived. Once a second it logs the same
numbers (fps-hud:), so a scripted drag (ui.dragWidget) can be measured from the log.
render_bench on corpus/raw/arw-sony-a7m3-compressed.arw (24 MP; loupe = 2560 px preview →
1920×1280), min of 7 runs, wall clock, on a heavily shared machine (load average 60–110 on 14 cores,
so CPU numbers are pessimistic; the GPU numbers are less affected):
| scenario | CPU | GPU |
|---|---|---|
| loupe 1920×1280 cold (new photo, incl. upload) | 415 ms | 32 ms |
| loupe draft 1152×768 cold | 169 ms | 21 ms |
| exposure drag 1920×1280 (warm) | 22–42 ms | 3.9 ms |
| highlights drag, draft (warm) | 14 ms | 3.2 ms |
| clarity drag, draft (warm) | 16–23 ms | 3.0 ms |
| NR drag, draft / 1920×1280 (warm) | 186 / 301–471 ms | 10 / 18 ms |
| per-pixel stage 1920×1280, + vibrance/saturation | 53–65 ms | 5 ms |
| export render 6000×4000 | 1037–1414 ms | 291–330 ms |
On Windows (Radeon 780M iGPU, DX12), a full-size JPEG export of the same raw with heavy edits (NR 30
/ 25, clarity, texture, dehaze, sharpening, highlights / shadows) took 3.6–4.0 s on the GPU against
1.9–2.0 s on the CPU: the horizontal box pass had each thread slide a running sum over 16 pixels, so
the threads of a workgroup read pixels 16 apart, and it took 9–15× as long as the vertical pass.
Summing each pixel's window instead (adjacent threads, adjacent pixels): 1.9–2.0 s, with the GPU's
NR stage 1.5 → 0.4 s and spatial planes 0.89 → 0.29 s (LIGHTCRAFT_PROFILE). The ignored test
bench_kernels (crates/gpu/src/render.rs) times the passes on a 24 MP plane.
GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel
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.
On Windows / DX12 the kernels cost more: FXC compiles each pipeline single-threaded inside
create_compute_pipeline (issue #471). Built one after another, the 27 took ~2.2 s on an Intel Core
Ultra 7 165U iGPU (app.gpu 2.4 s median, up to 7 s on the first run after an install), so
ctx::build_kernels shares them out to worker threads (issue #2936): ~1.05 s there (app.gpu 1.2 s),
most likely the largest kernel (finish.wgsl) alone (not timed per kernel). wgpu's error scopes are per thread, so every worker
opens its own; a refused kernel still means no GPU. The gpu: info log line gives the kernel count
and build time.
Drawing renders straight from the GPU
The desktop app creates the window's wgpu device with the adapter's limits and hands it to the GPU
renderer (lightcraft_gpu::adopt_window_device, on a background thread at startup: the kernels
build there; the crash sentinel covers it), so rendering and drawing share one device. A slider
drag's drafts of the views (Detail, Before, the zoomed window, hover, Compare, the second window)
are then marked RenderJob::on_screen: the render ends in an Rgba8Unorm texture
(lightcraft_gpu::render_display → Output::Display) that egui-wgpu draws as a native texture
(ScreenTextures in ui-egui, implemented by the app) - no readback, no conversion to egui's
pixels, no re-upload. The per-render checks run on the device instead: every pixel written (an
atomic count), not entirely black (the device histogram). Rows are re-laid at a 256-byte stride
for the texture copy when the width needs it.
Renders that need pixels keep the readback: the settled full-quality render after a drag (the cached
view preview, overlays, screenshots), thumbnails, exports, overlays (Visualize Spots, mask
overlays), windows that spots grew (cut on the CPU), a monitor profile (corrected on the CPU), and
anything the GPU path declines. A device that doesn't suit (software adapter, too few storage
buffers, kernels that don't build there) makes the renderer create a device of its own, as before.
A headless ui.screenshot (the control channel keeps CPU copies of what is drawn) taken while a
view shows a GPU-resident draft reads that texture back for the picture (ScreenTextures::read,
lightcraft_gpu::read_texture); drawing never does.
LIGHTCRAFT_GPU_WINDOW=0: never adopt the window's device (a device of its own, renders read back) - to compare, or for a driver that misbehaves with compute and drawing on one device.ui.inspect→perf.gpuWindowDevice(renders run on the window's device),perf.screenTextures(the UI can draw GPU-resident renders),perf.mainOnGpu(the loupe shows one now).- Measured (RTX 5070, DX12, a 45 MP CR3, 90 exposure steps of a drag at 30 Hz): the loupe's draft render 1.69 ms median from the texture vs 2.05 ms with the readback; the UI thread unchanged within noise (~1 ms). The difference grows with the loupe's size (4K/5K displays, zoom).
- With the renderer on the window's device, errors the device raises outside a render (the window's own included) are logged and stop GPU rendering (CPU from then on) instead of wgpu's default panic.
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.
Memory (M5.6)
A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles with no zoom window on screen (a zoomed view keeps it for its next pan: a 64 MP original is 800 MB to upload again; issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again).
library.memoryreports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired);ui.inspect→memoryadds the loupe's stage caches (CPU images, GPU buffers) and the textures.- Heap profile: build
lightcraft-cliwith--features dhat-heap;library.memorythen also reports live/peak heap bytes and the run writesdhat-heap.json(LIGHTCRAFT_DHAT_FILE), whose allocation sites at the peak (t-gmax) show who holds the memory. - Measuring the scenario (import 14 raws from
corpus/raw, open the loupe, step 12 times):/usr/bin/time -l lightcraft-cli snapshot <files> --script steps.jsonl -o out.png→ "maximum resident set size" and "peak memory footprint". Run it several times: the high-water mark is noisy (allocator caching, scheduling). On Apple silicon GPU buffers count in the footprint. - Imports read raw headers only (
lightcraft_raw::probe_info): no pixel data is decompressed, except the first rows a few vendor formats need for facts their headers lack (Nikon NEF: the first 128 rows for the masked trailing columns, issue #708). - One budget (
memory::budget, default min(25 % of RAM, 1.5 GiB);LIGHTCRAFT_MEMORY_MBorapp.memoryBudget {mb}): half for the engine caches (decoded thumbnail / preview / full sources and rendered previews, evicted least recently used across them; the photo on screen stays), a quarter for decodes in flight (memory::work_gate: grid thumbnails, neighbour prefetch and import probes wait for room, loupe and export loads never wait), an eighth for the GPU buffer pool (reuse by best fit within 25 %; freed after 3 s idle). - Decodes free the file's bytes and the raw samples as soon as they are consumed and colour /
crop / orient in place. macOS keeps freed large blocks resident ("reusable") until the system
is short of memory, so the resident size climbed with every photo decoded; the apps install
malloc_zone_pressure_reliefasmemory::set_release_hook, called after each decode, import and idle trim. - Result (M4 Pro, 14 raws, loupe, 12 steps): peak RSS 1.9–2.3 GB → 0.83–0.99 GB, peak footprint 2.5–2.6 GB → 0.91–0.96 GB, heap peak 930 → ~600 MB; stepping median ~13 ms (unchanged).
Open items
- WebGPU in the browser build (async init, single device per canvas).
- Sky/Subject heuristics (replaced by the segmenter in M12); the ProRAW local tone map.
- Tiling for sources / outputs beyond the buffer limit (24 MP+ on WebGPU-default limits, 60 MP+ on most desktop adapters; these render on the CPU now): tile with overlap equal to the largest filter radius, with global values (airlight) from a first pass.
- Integrated GPUs much slower than the CPU (issue #78's GT1): a measured per-device choice of the faster path for exports.
- Large-radius blurs at full size (dehaze dark channel at 24 MP) dominate the export: a summed-area table or a downsampled dark channel would cut them further.