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>
This commit is contained in:
Valentin Klein
2026-11-06 17:37:23 +01:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 8948a7cdc9
commit fdcc715829
31 changed files with 3081 additions and 250 deletions
+206
View File
@@ -0,0 +1,206 @@
//! The viewer's display pass (`viewer::DisplayPass`): exposure on the float range of 32 bpc
//! frames, the HDR step after exposure, the display colour after both, and the frame encodings.
use effectcraft_color::{ColorSpace, linear_to_srgb};
use effectcraft_project::HdrMode;
use effectcraft_render::RenderOpts;
use effectcraft_render::color::{compand, tone_map};
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::viewer::{Channel, DisplayColor, DisplayPass, FrameEncoding, PassCurve, SimProfile, Simulation};
const SRGB: FrameEncoding = FrameEncoding { curve: PassCurve::Srgb, hdr: HdrMode::Clip };
fn rgb(encoding: FrameEncoding, stops: f32) -> DisplayPass {
DisplayPass::new(Channel::Rgb, false, stops, encoding, None)
}
/// An opaque grey of linear light `l`, sRGB-encoded as 32 bpc frames store it.
fn grey(l: f32) -> [f32; 4] {
let e = PassCurve::Srgb.encode(l);
[e, e, e, 1.0]
}
fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8
}
/// The issue's case: over-range values come back with negative exposure (4.0 at -2 EV is white)
/// and 0.5 at +1 EV is white; values of the 8-bit range keep today's bytes.
#[test]
fn exposure_reaches_over_range_values() {
let table: [(f32, [u8; 5]); 6] = [
(0.25, [137, 99, 71, 34, 188]),
(0.5, [188, 137, 99, 49, 255]),
(1.0, [255, 188, 137, 71, 255]),
(2.0, [255, 255, 188, 99, 255]),
(4.0, [255, 255, 255, 137, 255]),
(16.0, [255, 255, 255, 255, 255]),
];
for (l, want) in table {
for (stops, w) in [0.0, -1.0, -2.0, -4.0, 1.0].into_iter().zip(want) {
assert_eq!(rgb(SRGB, stops).pixel(grey(l))[0], w, "linear {l} at {stops} EV");
}
}
assert_eq!(rgb(SRGB, -2.0).pixel(grey(4.0)), [255, 255, 255, 255]);
assert_eq!(rgb(SRGB, 1.0).pixel(grey(0.5)), [255, 255, 255, 255]);
// Premultiplied: alpha stays, colour scales with it.
let e = PassCurve::Srgb.encode(4.0);
let half = rgb(SRGB, -2.0).pixel([e * 0.5, e * 0.5, e * 0.5, 0.5]);
assert!(half[3] == 128 && half[..3].iter().all(|c| (127..=128).contains(c)), "{half:?}");
}
/// Compand / Tone Map run after exposure: a value under the knee that exposure pushes over it
/// is companded (not clipped), and highlights darkened by exposure are not companded first.
#[test]
fn the_hdr_step_runs_after_exposure() {
let compand_enc = FrameEncoding { curve: PassCurve::Srgb, hdr: HdrMode::Compand };
// 0.6 is below the 0.8 knee: unchanged at 0 EV, companded to about 0.97 at +1 EV.
assert_eq!(rgb(compand_enc, 0.0).pixel(grey(0.6))[0], q(linear_to_srgb(0.6)));
let up = rgb(compand_enc, 1.0).pixel(grey(0.6))[0];
assert_eq!(up, q(linear_to_srgb(compand(1.2))));
assert!(up < 255, "companded after exposure, not clipped: {up}");
// 3.0 at -2 EV is 0.75, under the knee: compand first would have made it 0.25.
let down = rgb(compand_enc, -2.0).pixel(grey(3.0))[0];
assert_eq!(down, q(linear_to_srgb(0.75)));
assert!(down > q(linear_to_srgb(compand(3.0) * 0.25)) + 50, "{down}");
// Tone Map (luminance): the same order.
let tm = FrameEncoding { curve: PassCurve::Srgb, hdr: HdrMode::ToneMap };
for (l, stops) in [(0.6f32, 1.0f32), (3.0, -2.0), (16.0, -1.0), (0.1, 0.0)] {
let k = 2f32.powf(stops);
let want = q(linear_to_srgb(tone_map([l * k; 3])[0]));
assert_eq!(rgb(tm, stops).pixel(grey(l))[0], want, "{l} at {stops}");
}
}
/// The display colour converts the exposed output numbers: exposure first, then the monitor
/// conversion (before: the conversion of the 8-bit frame, then exposure).
#[test]
fn display_colour_follows_exposure() {
let dc =
DisplayColor::new(Some(ColorSpace::Srgb), false, ColorSpace::Srgb, true, ColorSpace::DisplayP3, Simulation::default(), &Default::default()).unwrap();
let pass = DisplayPass::new(Channel::Rgb, false, 1.0, SRGB, Some(&dc));
let red = [PassCurve::Srgb.encode(0.4), 0.0, 0.0, 1.0];
let exposed = [linear_to_srgb(0.8), 0.0, 0.0];
let want = dc.apply_straight(exposed).map(q);
assert_eq!(pass.pixel(red)[..3], want);
// The conversion is not applied to a single channel's view.
assert!(DisplayPass::new(Channel::Red, false, 0.0, SRGB, Some(&dc)).color.is_none());
}
/// The pass's display colour is `DisplayColor::apply_straight`: the same conversions in the same
/// order (a few ULPs apart where the pass uses `f32` for PQ / HLG).
#[test]
fn pass_colour_matches_display_color() {
let custom = Default::default();
let mut cases = vec![];
for display in [ColorSpace::DisplayP3, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::Srgb] {
for profile in SimProfile::ALL {
for preserve_rgb in [false, true] {
let sim = Simulation { profile, preserve_rgb };
cases.extend(DisplayColor::new(Some(ColorSpace::Srgb), false, ColorSpace::Srgb, true, display, sim, &custom));
}
}
cases.extend(DisplayColor::new(Some(ColorSpace::Rec2020), true, ColorSpace::Srgb, false, display, Simulation::default(), &custom));
}
// A LUT-based profile (an uneven, channel-mixing grid).
cases.push(DisplayColor::from_lut(std::sync::Arc::new(test_lut())));
assert!(cases.len() > 40);
for dc in &cases {
let pass = dc.pass();
for i in 0..=20 {
for c in [[i as f32 / 20.0, 0.3, 0.9], [0.05, i as f32 / 20.0, 0.5], [1.0, 1.0, i as f32 / 20.0]] {
let (a, b) = (pass.apply(c), dc.apply_straight(c));
for k in 0..3 {
// The simulation's 8-bit step can land either side of a tie.
assert!((a[k] - b[k]).abs() < 1e-4 || ((a[k] - b[k]).abs() < 1.5 / 255.0 && c != [1.0; 3]), "{dc:?} {c:?}: {a:?} vs {b:?}");
}
}
}
}
}
/// A 17³ LUT that mixes channels and bends the curve (as an ICC profile's device does).
pub(crate) fn test_lut() -> effectcraft_color::icc::Lut3d {
effectcraft_color::icc::Lut3d::bake(17, |c| [c[0].powf(1.3) * 0.9 + c[1] * 0.1, c[1] * 0.8 + c[2] * 0.2, (c[2] * 0.7 + c[0] * 0.3).sqrt()])
}
/// Frames keep their encoding: the output space's curve (the Working Gamma's when unmanaged) and
/// the HDR step only when it was skipped and applies (managed, SDR output).
#[test]
fn frame_encodings() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 8, "height": 8, "duration": 1})).unwrap();
assert_eq!(FrameEncoding::of(&s.project.settings, true), SRGB, "unmanaged: sRGB at Working Gamma 2.2, no HDR step");
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "srgb", "hdr": "compand"})).unwrap();
let settings = s.project.settings.clone();
assert_eq!(FrameEncoding::of(&settings, true), FrameEncoding { curve: PassCurve::Srgb, hdr: HdrMode::Compand });
assert_eq!(FrameEncoding::of(&settings, false), SRGB, "rendered with the step: nothing pending");
s.execute("file.projectSettings", json!({"outputSpace": "rec2100pq"})).unwrap();
assert_eq!(FrameEncoding::of(&s.project.settings, true), FrameEncoding { curve: PassCurve::Pq, hdr: HdrMode::Clip }, "HDR outputs never tone map");
s.execute("file.projectSettings", json!({"outputSpace": "rec709"})).unwrap();
assert_eq!(FrameEncoding::of(&s.project.settings, true).curve, PassCurve::Gamma(2.4));
}
/// A 32 bpc Compand project with an over-range layer: `FrameEncoding::finish` on the frame
/// rendered without the HDR step gives the frame rendered with it, and Take Snapshot (now taken
/// without the step) exports the same bytes as before, within a rounding step.
#[test]
fn the_skipped_hdr_step_finishes_like_the_render() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 16, "height": 8, "duration": 1})).unwrap();
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "srgb", "hdr": "compand"})).unwrap();
let lid = s.execute("layer.newSolid", json!({"color": "#c08040", "width": 16, "height": 8})).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": 1.5})).unwrap();
let cid = s.active_comp_id().unwrap();
let with = s.render(cid, Tick::ZERO, RenderOpts::default());
let without = s.render(cid, Tick::ZERO, RenderOpts { skip_hdr_step: true, ..Default::default() });
let enc = FrameEncoding::of(&s.project.settings, true);
assert_eq!(enc.hdr, HdrMode::Compand);
let (w, o) = (with.data[0], without.data[0]);
assert!(o[0] > 1.0 && w[0] < 1.0, "over range without the step, companded with it: {o:?} {w:?}");
let f = enc.finish([o[0], o[1], o[2]]);
for k in 0..3 {
assert!((f[k] - w[k]).abs() < 1e-4, "{f:?} vs {w:?}");
}
s.execute("view.takeSnapshot", json!({})).unwrap();
assert_eq!(s.snapshot.as_ref().unwrap().encoding, enc);
let (_, _, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap();
let before = w.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
for k in 0..4 {
assert!((rgba[k] as i32 - before[k] as i32).abs() <= 1, "{:?} vs {before:?}", &rgba[..4]);
}
// 8 bpc snapshots are rendered with the step, as before.
s.execute("file.projectSettings", json!({"bitDepth": 8})).unwrap();
s.execute("view.takeSnapshot", json!({})).unwrap();
assert_eq!(s.snapshot.as_ref().unwrap().encoding.hdr, HdrMode::Clip);
}
/// The 8-bit CPU path's tables give what the pass gives pixel by pixel: every value of every
/// channel view, opaque and not, with and without exposure.
#[test]
fn the_8_bit_tables_are_the_pass() {
for (channel, colorized) in
[(Channel::Rgb, false), (Channel::RgbStraight, false), (Channel::Red, false), (Channel::Green, true), (Channel::Blue, false), (Channel::Alpha, false)]
{
for stops in [0.0f32, 1.0, -2.5] {
let pass = DisplayPass::new(channel, colorized, stops, SRGB, None);
let px: Vec<[u8; 4]> = (0..256 * 4)
.map(|i| {
let v = (i % 256) as u8;
let a = [255u8, 255, 128, 3][i / 256];
let c = |k: u8| ((v as u32 * k as u32) % 256 * a as u32 / 255) as u8;
[c(1), c(7), c(13), a]
})
.collect();
let mut got = px.clone();
pass.apply_rgba8(&mut got);
for (p, g) in px.iter().zip(&got) {
assert_eq!(*g, pass.pixel(p.map(|v| v as f32 / 255.0)), "{channel:?} {colorized} {stops}: {p:?}");
}
}
}
}
+17 -13
View File
@@ -8,7 +8,7 @@ use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap};
use crate::viewer::{Channel, DisplayPass, FrameEncoding, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, PassCurve, SnapKind, SnapSource, SnapTarget, snap};
fn comp() -> Session {
let mut s = Session::default();
@@ -225,15 +225,19 @@ fn snapping_options_command_sets_and_toggles() {
// ---------------------------------------------------------------- channels, exposure, snapshot
/// The display pass on 8-bit frames (sRGB, no HDR step): Show Channel and exposure.
fn display_rgba8(px: [u8; 4], channel: Channel, colorized: bool, stops: f32) -> [u8; 4] {
let encoding = FrameEncoding { curve: PassCurve::Srgb, hdr: effectcraft_project::HdrMode::Clip };
let mut v = [px];
DisplayPass::new(channel, colorized, stops, encoding, None).apply_rgba8(&mut v);
v[0]
}
#[test]
fn channel_and_exposure_pixel_transforms() {
// Premultiplied: straight (200, 100, 50) at 50% alpha.
let px = [100u8, 50, 25, 128];
let run = |c: Channel, col: bool, stops: f32| {
let mut v = [px];
display_transform(&mut v, c, col, stops);
v[0]
};
let run = |c: Channel, col: bool, stops: f32| display_rgba8(px, c, col, stops);
assert_eq!(run(Channel::Rgb, false, 0.0), px);
assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]);
assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]);
@@ -242,13 +246,13 @@ fn channel_and_exposure_pixel_transforms() {
let st = run(Channel::RgbStraight, false, 0.0);
assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}");
// +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity.
let mut g = [[128u8, 128, 128, 255]];
display_transform(&mut g, Channel::Rgb, false, 1.0);
assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]);
let mut g = [[128u8, 128, 128, 255]];
display_transform(&mut g, Channel::Rgb, false, -1.0);
assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]);
assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200);
let g = display_rgba8([128, 128, 128, 255], Channel::Rgb, false, 1.0);
assert!((174..=178).contains(&g[0]), "{g:?}");
let g = display_rgba8([128, 128, 128, 255], Channel::Rgb, false, -1.0);
assert!((92..=96).contains(&g[0]), "{g:?}");
for v in 0..=255u8 {
assert_eq!(display_rgba8([v, v, v, 255], Channel::Rgb, false, 0.0), [v, v, v, 255]);
}
// Exposure applies to a single channel too, and keeps alpha untouched in RGB.
assert!(run(Channel::Red, false, 1.0)[0] > 100);
assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128);