mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 03:22:22 +00:00
* 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>
284 lines
15 KiB
Rust
284 lines
15 KiB
Rust
//! 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}");
|
||
}
|