mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 10:20:47 -06: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>
207 lines
10 KiB
Rust
207 lines
10 KiB
Rust
//! 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:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|