Files
Valentin KleinandClaude Sonnet 5.5 fdcc715829 Viewer exposure on the full float range of 32 bpc frames (#2937)
* Viewer exposure on the full float range of 32 bpc frames (#2797)

32 bpc viewer frames on the desktop are rendered without the HDR step (skip_hdr_step,
#2916) and kept as half floats in the RAM preview. A display pass on egui-wgpu's device
turns each frame into one RGBA8 texture with mips per viewer, in this order: decode,
exposure in linear light, Compand / Tone Map, encode, display colour, Show Channel,
rounding. Before, exposure was an 8-bit lookup applied after tone mapping, clamping and
the display conversion, so -2 EV could not bring values above 1.0 back.

- engine: viewer::DisplayPass describes the pass as data; its pixel() is the CPU
  fallback (no device) and the shader's reference. It replaces exposure_lut /
  display_transform. FrameEncoding records a frame's curve and the HDR step it still
  needs. 32 bpc snapshots are taken without the HDR step; snapshot_rgba8 applies it.
- ui-egui: FrameImage::CpuF16 (half crate, F16C at run time), byte accounting at 8 bytes
  a pixel in the cache and the prefetch estimates; display_gpu.rs + display_gpu.wgsl;
  main viewer, snapshot and passive viewers draw through the pass; the 8-bit image other
  panels read is made on demand with the HDR step; the Info panel shows float values.
- Half-float frames are not written to the disk cache yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewer display pass: f16 disk cache entries, window-device gating, review fixes

- Disk cache: half-float frame entries (format byte in the entry header, byte planes +
  LZ4); 8-bit entries keep format 0 and stay valid. 32 bpc viewer frames are written and
  read again, and the timeline's disk bar sees them. A damaged half-float entry is
  deleted.
- The display pass runs on the window's egui-wgpu device, with or without the GPU
  compositor (still building, failed, GL fallback). Its pipelines are made inside error
  scopes; a backend that rejects them gets the CPU pass instead of a blank viewer.
- A new LUT of the same size (another ICC profile, Preserve RGB, another display) runs
  the pass again.
- f32 to f16: one pass per L1-sized block, the NaN / infinity fix-up on the bits, no
  fill of the buffer (4K: 4.7 ms, as the 8-bit conversion).
- Mips of odd-sized levels include the last column / row; the snapshot's half floats
  and texture go with the snapshot; no placeholder texture ids; u64 size checks.
- Tests: LUT profiles and negative values against the shader, Bake reruns, the passive
  viewer's colour, f16 disk round trips; exact comparisons allow 1 LSB on software
  rasterizers. Docs: the display pass and the f16 disk entries in architecture.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewer f16 disk-cache test: temp dir removed on failure too, wait for the queued disk write

Review points: the test's directory was only removed by its last statement
(now tempfile::TempDir), and the frame is released before its disk write is
queued, so flush() could return early (now waits for the entry).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Viewer: oversized half-float frames use the CPU display pass, so exposure/HDR come before the averaging down

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Viewer: fmt and a debug assert for the half-float upload path

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Disk cache: persist_missing also writes half-float frames

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Passive viewer: oversized half-float frames take the CPU display pass instead of leaving a stale texture

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:37:23 +01:00

284 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The viewer on the GPU compositor (egui_kittest with wgpu): frames composited on the GPU match
//! the CPU viewer. Skips without a GPU adapter (CI). Set `EC_SNAPSHOT_DIR` to keep PNGs.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui_kittest::Harness;
use serde_json::json;
/// Step until the GPU compositor's worker is done and a frame has landed in the viewer.
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..3000 {
h.step();
if !h.state().gpu_initializing() && h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for _ in 0..4 {
h.step();
}
}
/// Step until the GPU compositor's worker has handed its result over (attached, or why not).
/// The first step starts the build.
pub(crate) fn wait_for_compositor(h: &mut Harness<'_, EffectcraftApp>) {
h.step();
for _ in 0..3000 {
if !h.state().gpu_initializing() {
return;
}
h.step();
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("the GPU compositor's worker gave no result within a minute");
}
/// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners
/// without a GPU skip).
fn any_adapter() -> bool {
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
!pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all())).is_empty()
}
fn small_session() -> Session {
let mut s = Session::default();
let comp = s.execute("comp.new", json!({"name": "First", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"comp": comp, "color": "#ff0000"})).unwrap();
s
}
/// The window opens before the GPU compositor exists: its pipelines compile on a worker thread
/// (tens of seconds on a cold shader cache), and until then the interface draws, says so, and
/// composites on the CPU. The worker is held on a gate so the "preparing" state is observable.
#[test]
fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_built() {
let _device = crate::gpu_test::lock();
if !any_adapter() {
eprintln!("no wgpu adapter: skipping");
return;
}
let (release, gate) = std::sync::mpsc::channel::<()>();
let s = small_session();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| {
let mut app = EffectcraftApp::new(s);
// The status line's 5 s expiry reads the app clock: a manual one makes the steps below
// stand for a known span of time.
app.use_manual_clock();
app.hooks.before_gpu_build = Some(Box::new(move || {
let _ = gate.recv();
}));
app
});
// The first frames paint, and a CPU frame reaches the viewer, while the worker still waits.
for _ in 0..600 {
h.step();
if h.state().is_ready() && !h.output().shapes.is_empty() && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(h.state().gpu_initializing(), "the compositor is still being built");
assert!(!h.output().shapes.is_empty(), "the interface draws before the compositor exists");
assert!(h.state().session.accel.is_none(), "nothing renders on a compositor that doesn't exist yet");
assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line is honest about it");
assert_eq!(h.state().session.accel_note.as_deref(), Some(effectcraft_ui_egui::GPU_PREPARING));
assert!(h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false), "a frame was composited on the CPU meanwhile");
assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows that frame");
// Other statuses clear after 5 s; this one stays for as long as the build takes (each step
// is a quarter of a second).
for _ in 0..30 {
h.state_mut().clock_mut().advance(0.25);
h.step();
}
assert!(h.state().gpu_initializing());
assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line still says so 7.5 s later");
release.send(()).unwrap();
settle(&mut h);
assert!(!h.state().gpu_initializing());
assert_ne!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the preparing status goes away");
match h.state().gpu_adapter() {
Some(adapter) => {
eprintln!("compositor attached after the first frames: {adapter}");
assert!(h.state().session.accel.is_some());
assert!(h.state().session.accel_note.is_none());
}
// An adapter that can't run the compositor (software GL): the reason is recorded.
None => assert!(h.state().session.accel_note.as_deref().is_some_and(|n| n != effectcraft_ui_egui::GPU_PREPARING)),
}
}
/// A graphics backend that panics while the compositor's pipelines compile panics on the worker
/// thread, not under the UI: the window keeps drawing on the CPU and says why.
#[test]
fn a_compositor_build_that_panics_leaves_the_viewer_on_the_cpu() {
let _device = crate::gpu_test::lock();
if !any_adapter() {
eprintln!("no wgpu adapter: skipping");
return;
}
let s = small_session();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| {
let mut app = EffectcraftApp::new(s);
app.hooks.before_gpu_build = Some(Box::new(|| panic!("synthetic shader compiler panic")));
app
});
settle(&mut h);
assert!(!h.state().gpu_initializing());
assert!(h.state().gpu_adapter().is_none());
assert!(h.state().session.accel.is_none());
let note = h.state().session.accel_note.clone().unwrap_or_default();
assert!(note.contains("synthetic shader compiler panic"), "{note}");
assert!(!h.output().shapes.is_empty(), "the interface still draws");
assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows a CPU frame");
}
/// egui_kittest's wgpu setup on the adapter the headless compositor picked (`name (Backend)`),
/// like the desktop app's window. Left alone, egui_kittest prefers a CPU adapter, which on
/// Windows is WARP on DirectX 12, where FXC fails to compile the compositor's kernels: the
/// viewer would stay on the CPU and the comparison would never run.
fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup {
let mut setup = egui_kittest::wgpu::default_wgpu_setup();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup {
let fallback = c.native_adapter_selector.take();
c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| {
let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name;
match (adapters.iter().find(named), &fallback) {
(Some(a), _) => Ok(a.clone()),
(None, Some(f)) => f(adapters, surface),
(None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()),
}
}));
}
setup
}
#[test]
fn viewer_draws_gpu_frames_that_match_the_cpu() {
let _device = crate::gpu_test::lock();
let Some(probe) = effectcraft_gpu::Gpu::headless() else {
eprintln!("no GPU adapter: skipping");
return;
};
let adapter = effectcraft_engine::render::Accelerator::name(&probe);
eprintln!("headless compositor adapter: {adapter}");
// egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines.
let gl = adapter.ends_with("(Gl)");
drop(probe);
let mut h = Harness::builder()
.with_size(egui::vec2(1400.0, 900.0))
.with_pixels_per_point(1.0)
.wgpu_setup(same_adapter(adapter))
.build_eframe(|_| EffectcraftApp::new(Session::default()));
// The project opens once the compositor (built on a worker thread) is attached. A frame the
// CPU composited while it was being built has the GPU frame's key, so the viewer would keep
// showing it, and the comparison below would be the CPU against itself.
wait_for_compositor(&mut h);
if gl && h.state().gpu_adapter().is_none() {
eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping");
return;
}
assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device");
eprintln!("viewer compositor adapter: {:?}", h.state().gpu_adapter());
h.state_mut().session.execute("file.openDemoProject", json!({})).unwrap();
h.state_mut().session.execute("time.set", json!({"time": 2.0})).unwrap();
settle(&mut h);
let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame");
let gpu_shot = h.render().expect("render");
// Mercury Software Only: the same frame from the CPU.
h.state_mut().session.execute("render.backend", json!({"backend": "cpu"})).unwrap();
settle(&mut h);
let cpu_px = h.state_mut().viewer_pixels().expect("CPU frame");
let cpu_shot = h.render().expect("render");
assert_eq!(gpu_px.size, cpu_px.size);
let mut worst = 0i32;
let mut over = 0usize;
for (a, b) in gpu_px.pixels.iter().zip(&cpu_px.pixels) {
let d = a.to_array().iter().zip(b.to_array()).map(|(x, y)| (*x as i32 - y as i32).abs()).max().unwrap_or(0);
worst = worst.max(d);
over += usize::from(d > 1);
}
assert!(over * 1000 <= gpu_px.pixels.len() * 3 && worst <= 4, "viewer GPU vs CPU: {over} pixels > 1/255, worst {worst}/255");
if let Ok(d) = std::env::var("EC_SNAPSHOT_DIR") {
gpu_shot.save(format!("{d}/ui_gpu_viewer.png")).unwrap();
cpu_shot.save(format!("{d}/ui_cpu_viewer.png")).unwrap();
}
}
/// Once the graphics device is lost, eframe's renderer cannot stage buffers and egui-wgpu
/// panicked on the main thread drawing the next frame (#591). The app now draws nothing,
/// auto-saves unsaved changes and closes its window instead.
#[test]
fn a_lost_device_draws_nothing_autosaves_and_closes() {
let dir = std::env::temp_dir().join(format!("ec-device-lost-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session { autosave_folder_override: Some(dir.clone()), ..Default::default() };
s.execute("comp.new", json!({"name": "Unsaved", "width": 64, "height": 64, "duration": 1})).unwrap();
assert!(s.is_dirty());
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s));
let told = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let log = told.clone();
h.state_mut().hooks.device_lost = Some(Box::new(move |saved| log.borrow_mut().push(saved)));
h.run_steps(3);
assert!(!h.output().shapes.is_empty(), "the UI draws before the loss");
let bridge = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&h.ctx);
h.state_mut().set_gpu_failure_bridge(bridge.clone());
bridge.report("GPU device lost (Unknown): Device is lost", true);
h.step();
let closing = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close));
assert!(closing, "the window closes");
assert!(h.output().shapes.is_empty(), "nothing for the lost renderer to stage");
h.step();
assert!(h.output().shapes.is_empty());
let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved");
assert!(std::path::Path::new(&saved).is_file(), "{saved}");
assert_eq!(*told.borrow(), vec![Some(Ok(saved))], "the user is told once, with the recovery copy");
// The close arrives: on macOS the app quits through `-[NSApplication terminate:]` instead of
// closing the window while AppKit's run loop is running (#341).
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
h.step();
let kept = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose));
assert_eq!(kept, cfg!(target_os = "macos"), "the platform quit closes the window on macOS");
assert_eq!(told.borrow().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// #2797 in the real viewer on a device: a 32 bpc frame of white +2 stops (about 4.0 linear)
/// goes through the display pass on egui-wgpu's device, so -2 EV still shows it white and -4 EV
/// a quarter of white (the 8-bit viewer showed a sixteenth of white there).
#[test]
fn the_viewer_exposes_32_bpc_frames_on_the_gpu() {
let _device = crate::gpu_test::lock();
if !any_adapter() {
eprintln!("no wgpu adapter: skipping");
return;
}
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Hdr", "width": 64, "height": 64, "duration": 1})).unwrap();
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "srgb"})).unwrap();
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layers": [lid], "effect": "Exposure"})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/master/exposure", "value": 2.0})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| EffectcraftApp::new(s));
settle(&mut h);
let p = h.state().viewer_f16().expect("a half-float viewer frame").pixel(0).unwrap();
let l = effectcraft_engine::color::srgb_to_linear(p[0] / p[3]);
assert!(l > 3.9, "{l}");
// Screen pixels of the frame's grey (the comp is 64 × 64 points in the viewer at most).
let count = |h: &mut Harness<'_, EffectcraftApp>, stops: f32| {
h.state_mut().session.state.viewer.exposure = stops;
for _ in 0..4 {
h.step();
}
assert!(h.state().viewer_display_texture().is_some(), "drawn by the display pass on the device");
let want = (effectcraft_engine::color::linear_to_srgb(l * 2f32.powf(stops)).clamp(0.0, 1.0) * 255.0).round() as i32;
let img = h.render().expect("render");
let n = img.pixels().filter(|px| px.0[..3].iter().all(|c| (*c as i32 - want).abs() <= 1)).count();
(want, n)
};
let (white, n) = count(&mut h, -2.0);
assert!(white == 255 && n >= 1000, "-2 EV: {n} white pixels");
let (grey, n) = count(&mut h, -4.0);
assert!((130..=145).contains(&grey) && n >= 1000, "-4 EV: {n} pixels of {grey}");
}