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
+91
View File
@@ -0,0 +1,91 @@
//! Half-float viewer frames (32 bpc projects on the desktop, #2797): the RAM preview keeps the
//! rendered frame's full range, without the HDR step, so viewer exposure, Compand / Tone Map and
//! the display colour run on it in the display pass (`crate::display_gpu`), in that order.
use effectcraft_engine::render::Image;
use effectcraft_engine::viewer::{DisplayPass, FrameEncoding};
use half::f16;
use half::slice::{HalfBitsSliceExt, HalfFloatSliceExt};
use rayon::prelude::*;
/// Values converted per piece: 16 KB of input, so the piece stays in L1 while its non-finite
/// values are fixed up.
const BLOCK: usize = 4096;
/// A premultiplied RGBA frame in half floats, encoded in the output space as rendered (values
/// above 1.0 kept), with how it is encoded. Never NaN or infinite (see [`F16Frame::from_image`]):
/// the display pass relies on it.
pub struct F16Frame {
pub width: u32,
pub height: u32,
/// RGBA, row by row, as IEEE binary16 bits.
data: Vec<u16>,
pub encoding: FrameEncoding,
}
/// Bytes per pixel of a half-float frame.
pub const F16_PIXEL_BYTES: usize = 8;
/// Half floats that are NaN become 0, infinities ±65504 (one pass, no branches on the data).
fn finite(bits: &mut [u16]) {
for b in bits {
let v = *b;
let special = v & 0x7c00 == 0x7c00;
let fixed = if v & 0x03ff != 0 { 0 } else { (v & 0x8000) | 0x7bff };
*b = if special { fixed } else { v };
}
}
impl F16Frame {
/// A rendered frame in half floats. NaN becomes 0 (NaN alpha is transparent, as in
/// `display_pixel`) and values past the half-float range become ±65504.
pub fn from_image(img: &Image, encoding: FrameEncoding) -> F16Frame {
let src = img.data.as_flattened();
// A zeroed allocation: fresh pages from the OS, no fill pass.
let mut data = vec![0u16; src.len()];
data.par_chunks_mut(BLOCK).zip(src.par_chunks(BLOCK)).for_each(|(out, inp)| {
out.reinterpret_cast_mut::<f16>().convert_from_f32_slice(inp);
finite(out);
});
F16Frame { width: img.width, height: img.height, data, encoding }
}
/// A frame from binary16 bits (the disk cache), made finite as [`F16Frame::from_image`] does.
pub fn from_bits(width: u32, height: u32, mut data: Vec<u16>, encoding: FrameEncoding) -> F16Frame {
data.resize(width as usize * height as usize * 4, 0);
data.par_chunks_mut(BLOCK).for_each(finite);
F16Frame { width, height, data, encoding }
}
/// The pixels as binary16 bits (RGBA, row by row).
pub fn bits(&self) -> &[u16] {
&self.data
}
pub fn size(&self) -> [usize; 2] {
[self.width as usize, self.height as usize]
}
pub fn bytes(&self) -> usize {
self.data.len() * 2
}
/// Pixel `i` (row-major) as `f32`.
pub fn pixel(&self, i: usize) -> Option<[f32; 4]> {
let p = self.data.get(i.checked_mul(4)?..i * 4 + 4)?;
Some([0, 1, 2, 3].map(|k| f16::from_bits(p[k]).to_f32()))
}
/// The frame through `pass` on the CPU (no GPU, or a single sample).
pub fn to_color_image(&self, pass: &DisplayPass) -> egui::ColorImage {
let px: Vec<egui::Color32> = self
.data
.par_chunks(4)
.map(|p| {
let [r, g, b, a] = pass.pixel([0, 1, 2, 3].map(|k| f16::from_bits(p[k]).to_f32()));
egui::Color32::from_rgba_premultiplied(r, g, b, a)
})
.collect();
egui::ColorImage::new(self.size(), px)
}
}
+182 -2
View File
@@ -1136,6 +1136,175 @@ fn timing_markers_and_expressions_still_change_the_frames() {
assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change");
}
const SRGB: effectcraft_engine::viewer::FrameEncoding =
effectcraft_engine::viewer::FrameEncoding { curve: effectcraft_engine::viewer::PassCurve::Srgb, hdr: effectcraft_engine::project::HdrMode::Clip };
/// Half floats keep over-range values (the point of #2797); NaN becomes 0 and values past the
/// half-float range ±65504.
#[test]
fn half_float_frames_keep_over_range_values() {
let mut img = effectcraft_engine::render::Image::new(3, 1);
img.data.copy_from_slice(&[[4.0, 16.0, 1.825, 1.0], [70_000.0, f32::NAN, -f32::INFINITY, 0.5], [0.1, -0.25, 1000.0, f32::NAN]]);
let f = F16Frame::from_image(&img, SRGB);
assert_eq!(f.pixel(0), Some([4.0, 16.0, half::f16::from_f32(1.825).to_f32(), 1.0]));
assert_eq!(f.pixel(1), Some([65504.0, 0.0, -65504.0, 0.5]));
let p = f.pixel(2).unwrap();
assert_eq!((p[2], p[3]), (1000.0, 0.0));
assert!((p[0] - 0.1).abs() < 1e-4 && (p[1] + 0.25).abs() < 1e-4);
assert_eq!(f.bytes(), 3 * F16_PIXEL_BYTES);
}
/// Oversized half-float frames take the CPU pass: display conversion, then averaging down. Linear
/// 8 and 0 alternating in Clip mode at -2 EV is white and black (byte 128 averaged); averaging the
/// scene values first would give 4.0 x 0.25 = 1.0, byte 255.
#[test]
fn oversized_half_float_frames_convert_before_averaging() {
use crate::display_gpu::Source;
use effectcraft_engine::viewer::{Channel, DisplayPass};
let mut img = effectcraft_engine::render::Image::new(2, 1);
img.data.copy_from_slice(&[[8.0, 8.0, 8.0, 1.0], [0.0, 0.0, 0.0, 1.0]]);
let lin =
effectcraft_engine::viewer::FrameEncoding { curve: effectcraft_engine::viewer::PassCurve::Linear, hdr: effectcraft_engine::project::HdrMode::Clip };
let f = Arc::new(F16Frame::from_image(&img, lin));
let pass = DisplayPass::new(Channel::Rgb, false, -2.0, lin, None);
let src = Source::F16(f.clone());
assert!(src.needs_cpu_pass(1) && !src.needs_cpu_pass(2));
assert!(!Source::Rgba8(Arc::new(egui::ColorImage::new([2, 1], vec![egui::Color32::WHITE; 2]))).needs_cpu_pass(1), "8-bit frames are averaged on the GPU");
let out = fit_texture_by(f.to_color_image(&pass), 1, 1);
assert_eq!(out.size, [1, 1]);
assert_eq!(out.pixels[0].to_array(), [128, 128, 128, 255]);
}
/// 65 536 colours spread over 0..1 (each channel on its own 16-bit grid), premultiplied by
/// `alpha`: the 8-bit parity input.
pub(crate) fn parity_frame(alpha: f32) -> effectcraft_engine::render::Image {
let mut img = effectcraft_engine::render::Image::new(256, 256);
for (i, p) in img.data.iter_mut().enumerate() {
let v = |k: usize| ((i * k) % 65_536) as f32 / 65_535.0;
*p = [v(1) * alpha, v(7_919) * alpha, v(104_729) * alpha, alpha];
}
img
}
/// At 0 EV the half-float viewer shows the bytes of the old 8-bit frames for values in 0..1: at
/// least 97.9 % of the colour bytes equal (alpha all), never more than 1 apart. The rest is the
/// half-float storage.
#[test]
fn half_float_frames_show_what_8_bit_frames_showed() {
use effectcraft_engine::viewer::{Channel, DisplayPass};
let pass = DisplayPass::new(Channel::Rgb, false, 0.0, SRGB, None);
for alpha in [1.0f32, 0.5] {
let img = parity_frame(alpha);
let f = F16Frame::from_image(&img, SRGB);
let new = f.to_color_image(&pass);
let old = to_color_image(&img);
let (mut same, mut total, mut max) = (0, 0, 0);
for (n, o) in new.pixels.iter().zip(&old.pixels) {
let (n, o) = (n.to_array(), o.to_array());
assert_eq!(n[3], o[3]);
for c in 0..3 {
total += 1;
same += (n[c] == o[c]) as usize;
max = max.max((n[c] as i32 - o[c] as i32).abs());
}
}
let share = same as f64 / total as f64;
eprintln!("alpha {alpha}: {:.2} % of colour bytes equal, max {max}", share * 100.0);
assert!(share >= 0.979 && max <= 1, "alpha {alpha}: {share} equal, max {max}");
}
}
/// The RAM preview counts half-float frames at 8 bytes a pixel: in a budget of three 8-bit
/// frames, a half-float frame of the same size takes the room of two.
#[test]
fn half_float_frames_count_twice_the_bytes() {
let f16 = || FrameImage::CpuF16(Arc::new(F16Frame::from_image(&effectcraft_engine::render::Image::new(2, 2), SRGB)));
assert_eq!(f16().bytes(), 32);
assert_eq!(img().bytes(), 16);
let frames = Frames::default();
frames.set_budget(48);
for i in 0..3 {
frames.cache.lock().unwrap().insert(key(1, i, 0), img());
}
assert_eq!(frames.cached_frames(&key(1, 0, 0)).len(), 3);
frames.cache.lock().unwrap().insert(key(2, 0, 0), f16());
let c = frames.cache.lock().unwrap();
assert!(c.bytes <= 48, "{}", c.bytes);
assert_eq!(c.map.len(), 2, "the half-float frame took two 8-bit frames' room");
}
/// 32 bpc viewer frames go to the disk cache as half floats and come back from it bit for bit
/// in a new session (over-range values kept); the timeline's disk bar sees them.
#[test]
fn half_float_frames_survive_in_the_disk_cache() {
use effectcraft_engine::Session;
use serde_json::json;
// Removed on drop, a failed assertion included.
let dir = tempfile::tempdir().unwrap();
let disk = DiskCache::open(dir.path(), 1 << 30).unwrap();
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"width": 16, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "srgb", "hdr": "compand"})).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 src = |disk: &Arc<DiskCache>| RenderSource {
project: s.project.clone(),
footage: s.footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: Some(disk.clone()),
};
let opts = RenderOpts { skip_hdr_step: true, ..Default::default() };
let shown = |f: &Frames, disk: &Arc<DiskCache>| {
let key = FrameKey {
revision: s.revision,
content: f.content_of(&s.project, s.revision, cid),
comp: cid.0,
frame: 0,
scale: 1000,
view: 0,
opts: opts_hash(&opts),
};
f.request_urgent(&src(disk), key, cid, Tick::ZERO, opts);
wait_for_renders(f);
match f.get(&key) {
Some(FrameImage::CpuF16(img)) => (key, img),
_ => panic!("a half-float frame"),
}
};
let first = Frames::default();
let (key, rendered) = shown(&first, &disk);
assert!(rendered.pixel(0).unwrap()[0] > 1.0, "over range, without the HDR step");
// The worker releases the frame before it queues the disk write: wait for the entry, not
// only for the queue to drain.
for _ in 0..400 {
disk.flush();
if disk.stats().entries >= 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(disk.stats().entries, 1, "written as a frame entry");
// The timeline's blue bar: on disk, not in this (new) RAM cache.
let again = Frames::default();
let key2 = FrameKey { content: again.content_of(&s.project, s.revision, cid), ..key };
for _ in 0..200 {
if !again.disk_frames(&src(&disk), &key2, 0..1, &opts).is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(again.disk_frames(&src(&disk), &key2, 0..1, &opts), vec![0]);
let hits = disk.stats().hits;
let (_, read) = shown(&again, &disk);
assert_eq!(disk.stats().hits, hits + 1, "read from the disk cache");
assert_eq!(read.bits(), rendered.bits());
assert_eq!(read.encoding, rendered.encoding);
}
/// The oracle for [`Comp::changed_spans`]: every frame an edit keeps renders the same in the edited
/// project. Moves, trims, splits, deletes and duplicates of moving clips, under adjustment
/// layers, with seeded noise, motion blur, mattes, parents and effects that read other times.
@@ -1415,9 +1584,19 @@ fn disk_keys_survive_save_and_reopen() {
/// Roto Brush model stamp in its opts; the disk keys are the worker's, without it.
#[test]
fn ram_frames_missing_on_disk_are_written() {
ram_frames_missing_on_disk_are_written_as(false);
}
/// Same for a 32 bpc project, whose RAM frames are half-float.
#[test]
fn half_float_ram_frames_missing_on_disk_are_written() {
ram_frames_missing_on_disk_are_written_as(true);
}
fn ram_frames_missing_on_disk_are_written_as(half: bool) {
use effectcraft_engine::Session;
use serde_json::json;
let dir = std::env::temp_dir().join(format!("ec-persist-{}", std::process::id()));
let dir = std::env::temp_dir().join(format!("ec-persist-{}-{half}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let disk = DiskCache::open(&dir, 1 << 30).unwrap();
let mut s = Session::default();
@@ -1436,7 +1615,8 @@ fn ram_frames_missing_on_disk_are_written() {
let series =
FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) };
for fr in 0..10 {
insert(&f, FrameKey { frame: fr, ..series });
let frame = if half { FrameImage::CpuF16(Arc::new(F16Frame::from_image(&effectcraft_engine::render::Image::new(8, 8), SRGB))) } else { img() };
f.cache.lock().unwrap().insert(FrameKey { frame: fr, ..series }, frame);
}
let on_disk = |fr: i64| {
disk.contains(