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 KleinandClaude Sonnet 5.5 authored and GitHub committed 2026-11-06 17:37:23 +01:00
1 parent 8948a7cdc9
commit fdcc715829
31 files changed
+3081 -250

No files matched your search

+12 -5
View File
@@ -159,9 +159,14 @@ fn take_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = s.time_of(cid);
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0);
let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() });
// 32 bpc: without the HDR step, so viewer exposure reaches the over-range values (the
// display pass applies the step, as for viewer frames).
let skip_hdr_step = s.project.settings.bit_depth.levels().is_none();
let opts = RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), skip_hdr_step, ..Default::default() };
let img = s.render(cid, t, opts);
let (w, h) = (img.width, img.height);
s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) });
let encoding = crate::viewer::FrameEncoding::of(&s.project.settings, skip_hdr_step);
s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img), encoding });
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"width": w, "height": h, "time": t.seconds()}))
}
@@ -298,15 +303,17 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": lid.0, "group": group, "path": path_uid}))
}
/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`).
/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`), with the HDR step a
/// 32 bpc snapshot was taken without.
pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec<u8>)> {
let snap = s.snapshot.as_ref()?;
let img = &snap.image;
let mut out = Vec::with_capacity(img.data.len() * 4);
for p in &img.data {
let a = p[3].clamp(0.0, 1.0);
let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 };
out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8));
let un = |c: f32| if a > 0.0 { c / a } else { 0.0 };
let c = snap.encoding.finish([un(p[0]), un(p[1]), un(p[2])]).map(|v| v.clamp(0.0, 1.0));
out.extend([c[0], c[1], c[2], a].map(|v| (v * 255.0 + 0.5) as u8));
}
Some((img.width, img.height, out))
}
+1
View File
@@ -11,6 +11,7 @@ mod color_view;
mod crypto_pick;
mod delete_keys;
mod disk_cache;
mod display_pass;
mod effects;
mod essential;
mod essential_more;
+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);
+13 -58
View File
@@ -1,5 +1,5 @@
//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and
//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot.
//! priority), the display pass (Show Channel, exposure, display colour), and the viewer snapshot.
//!
//! Nothing here touches the project; edits go through commands.
@@ -15,6 +15,9 @@ use effectcraft_render::{EvalCtx, ExprHost};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
mod display_pass;
pub use display_pass::{DisplayPass, FrameEncoding, PassColor, PassConv, PassCurve, SHADER_EXPONENTS, hdr_step, lut_eval};
// ---------------------------------------------------------------- snapping
/// What a snap target is: a point (snaps both axes) or an axis-aligned line.
@@ -500,63 +503,6 @@ fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
/// 8-bit exposure lookup table: `stops` applied in linear light.
pub fn exposure_lut(stops: f32) -> [u8; 256] {
let k = 2f32.powf(stops);
let mut lut = [0u8; 256];
for (i, o) in lut.iter_mut().enumerate() {
let lin = srgb_to_linear(i as f32 / 255.0) * k;
*o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
lut
}
/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place.
/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque
/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the
/// unpremultiplied colour, opaque.
pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) {
let lut = (stops != 0.0).then(|| exposure_lut(stops));
let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v);
for p in px.iter_mut() {
let [r, g, b, a] = *p;
*p = match channel {
Channel::Rgb => {
if lut.is_none() {
continue;
}
// Exposure on straight colour, re-premultiplied.
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8;
[pm(r), pm(g), pm(b), a]
}
Channel::Red | Channel::Green | Channel::Blue => {
let (i, c) = match channel {
Channel::Red => (0, r),
Channel::Green => (1, g),
_ => (2, b),
};
let v = ex(c);
if colorized {
let mut o = [0, 0, 0, 255];
o[i] = v;
o
} else {
[v, v, v, 255]
}
}
Channel::Alpha => {
let v = ex(a);
[v, v, v, 255]
}
Channel::RgbStraight => {
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
[ex(un(r)), ex(un(g)), ex(un(b)), 255]
}
};
}
}
// ---------------------------------------------------------------- display colour management
/// View ▸ Simulate Output: the output device a preview imitates.
@@ -999,6 +945,12 @@ impl DisplayColor {
)
}
/// A conversion that is a baked 3D LUT alone, as a LUT-based My Custom RGB profile makes
/// (tests of the display pass).
pub fn from_lut(lut: Arc<effectcraft_color::icc::Lut3d>) -> DisplayColor {
DisplayColor { sim: None, display: None, lut: Some(lut) }
}
/// Whether the simulation runs a LUT-based ICC profile (baked into a 3D LUT).
pub fn is_lut(&self) -> bool {
self.lut.is_some()
@@ -1097,6 +1049,9 @@ pub struct Snapshot {
/// Render scale the image was taken at.
pub scale: f64,
pub image: Arc<Image>,
/// How `image` is encoded: rendered without the HDR step in 32 bpc, so viewer exposure
/// reaches its over-range values; the display pass applies the step.
pub encoding: FrameEncoding,
}
#[cfg(test)]
+404
View File
@@ -0,0 +1,404 @@
//! The viewer's display pass as plain data: what turns a rendered frame into the pixels the
//! Composition panel shows. The frontend runs it in a shader. The same steps on the CPU
//! ([`DisplayPass::pixel`]) are its fallback without a GPU and the reference the shader is tested
//! against, so both use the same `f32` operations in the same order.
//!
//! Steps, per pixel of a premultiplied frame encoded in the output space:
//! 1. Unpremultiply (NaN alpha counts as 0, NaN colour as 0).
//! 2. When exposure or an HDR step applies: decode with the frame's curve, multiply by 2^stops
//! in linear light, run the pending HDR step (Project Settings ▸ HDR: Compand / Tone Map, for
//! frames rendered without it), encode again.
//! 3. Clamp to 0..1: the output's numbers.
//! 4. The display colour conversion (Use Display Color Management, Simulate Output), on RGB and
//! RGB Straight only.
//! 5. Show Channel, premultiply (without step 2 or a display conversion: the frame's own
//! premultiplied values, clamped to alpha), and round to 8 bits with `floor(v · 255 + 0.5)`.
//!
//! Exposure therefore acts on the full float range of 32 bpc frames, before tone mapping and
//! before the display conversion. With neither exposure nor an HDR step, step 2 is skipped, so
//! 8-bit frames pass through unchanged.
use std::sync::Arc;
use effectcraft_color::icc::Lut3d;
use effectcraft_color::space;
use effectcraft_project::{HdrMode, ProjectSettings};
use effectcraft_render::color::Pipe;
use super::{Channel, Conv, Curve, DisplayColor};
/// A transfer curve of the display pass.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PassCurve {
Linear,
Srgb,
/// A pure power curve (BT.1886 2.4, Mac 1.8, custom gammas).
Gamma(f32),
Pq,
Hlg,
}
/// SMPTE ST 2084 and BT.2100 HLG constants (`effectcraft_color::space`), in `f32`.
const PQ_M1: f32 = 2610.0 / 16384.0;
const PQ_M2: f32 = 2523.0 / 4096.0 * 128.0;
const PQ_C1: f32 = 3424.0 / 4096.0;
const PQ_C2: f32 = 2413.0 / 4096.0 * 32.0;
const PQ_C3: f32 = 2392.0 / 4096.0 * 32.0;
/// Linear 1.0 as a share of PQ's 10 000 cd/m² (the 203 cd/m² reference white).
const PQ_WHITE: f32 = 203.0 / 10_000.0;
const HLG_A: f32 = 0.178_832_77;
const HLG_B: f32 = 0.284_668_92;
const HLG_C: f32 = 0.559_910_7;
const HLG_REFERENCE: f32 = 0.264_962_56;
/// The exponents the shader is handed (`vals2.yzw`): 1 / 2.4, 1 / PQ m2, 1 / PQ m1.
pub const SHADER_EXPONENTS: [f32; 3] = [1.0 / 2.4, 1.0 / PQ_M2, 1.0 / PQ_M1];
/// `a^g` for `a ≥ 0`, 0 at 0 (WGSL leaves `pow(0, g)` to the implementation).
fn power(a: f32, g: f32) -> f32 {
if a <= 0.0 { 0.0 } else { a.powf(g) }
}
impl PassCurve {
/// The curve a colour space encodes with.
pub fn of(curve: space::Curve) -> PassCurve {
match curve {
space::Curve::Linear => PassCurve::Linear,
space::Curve::Srgb => PassCurve::Srgb,
space::Curve::Gamma24 => PassCurve::Gamma(2.4),
space::Curve::Pq => PassCurve::Pq,
space::Curve::Hlg => PassCurve::Hlg,
}
}
fn of_viewer(c: Curve) -> PassCurve {
match c {
Curve::Srgb => PassCurve::Srgb,
Curve::Gamma(g) => PassCurve::Gamma(g),
Curve::Linear => PassCurve::Linear,
Curve::Space(c) => PassCurve::of(c),
}
}
/// The shader's curve id.
pub fn id(self) -> u32 {
match self {
PassCurve::Linear => 0,
PassCurve::Srgb => 1,
PassCurve::Gamma(_) => 2,
PassCurve::Pq => 3,
PassCurve::Hlg => 4,
}
}
/// The shader's parameter to decode with: the gamma (1 for other curves).
pub fn decode_param(self) -> f32 {
if let PassCurve::Gamma(g) = self { g } else { 1.0 }
}
/// The shader's parameter to encode with: 1 / the gamma, as [`PassCurve::encode`] divides.
pub fn encode_param(self) -> f32 {
if let PassCurve::Gamma(g) = self { 1.0 / g } else { 1.0 }
}
/// Encoded → linear light, mirrored around zero.
pub fn decode(self, v: f32) -> f32 {
let a = v.abs();
let l = match self {
PassCurve::Linear => a,
PassCurve::Srgb => {
if a <= 0.04045 {
a / 12.92
} else {
power((a + 0.055) / 1.055, 2.4)
}
}
PassCurve::Gamma(g) => power(a, g),
PassCurve::Pq => {
let p = power(a.min(1.0), 1.0 / PQ_M2);
power((p - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * p), 1.0 / PQ_M1) / PQ_WHITE
}
PassCurve::Hlg => {
let e = if a <= 0.5 { a * a / 3.0 } else { (((a - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 };
e / HLG_REFERENCE
}
};
l.copysign(v)
}
/// Linear light → encoded, mirrored around zero.
pub fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
PassCurve::Linear => a,
PassCurve::Srgb => {
if a <= 0.003_130_8 {
a * 12.92
} else {
1.055 * power(a, 1.0 / 2.4) - 0.055
}
}
PassCurve::Gamma(g) => power(a, 1.0 / g),
PassCurve::Pq => {
let y = power(a * PQ_WHITE, PQ_M1);
power((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2)
}
PassCurve::Hlg => {
let e = a * HLG_REFERENCE;
if e <= 1.0 / 12.0 { (3.0 * e).sqrt() } else { HLG_A * (12.0 * e - HLG_B).ln() + HLG_C }
}
};
e.copysign(v)
}
}
/// A conversion of the display colour: decode, an optional linear-light matrix, encode.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PassConv {
pub from: PassCurve,
/// Rows of the linear RGB matrix (`None`: the same primaries).
pub matrix: Option<[[f32; 3]; 3]>,
pub to: PassCurve,
}
impl PassConv {
fn of(c: &Conv) -> PassConv {
PassConv { from: PassCurve::of_viewer(c.from), matrix: c.m, to: PassCurve::of_viewer(c.to) }
}
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let l = c.map(|v| self.from.decode(v));
let l = match &self.matrix {
Some(m) => [0, 1, 2].map(|i| m[i][0] * l[0] + m[i][1] * l[1] + m[i][2] * l[2]),
None => l,
};
l.map(|v| self.to.encode(v))
}
}
/// The display colour conversion ([`DisplayColor`]) as data for the display pass.
#[derive(Clone, Debug)]
pub enum PassColor {
/// One conversion; with Simulate Output, the conversion to the simulated device, its 8-bit
/// numbers (`quantize`) and the conversion from the device to the display.
Convs { first: PassConv, quantize: bool, second: Option<PassConv> },
/// A LUT-based My Custom RGB profile: the whole simulation baked into a 3D LUT.
Lut(Arc<Lut3d>),
}
impl PassColor {
/// One straight colour (0..1) to the display's numbers (0..1).
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let o = match self {
PassColor::Lut(l) => lut_eval(l, c),
PassColor::Convs { first, quantize, second } => {
let mut o = first.apply(c);
if *quantize {
o = o.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5).floor() / 255.0);
}
match second {
Some(s) => s.apply(o),
None => o,
}
}
};
o.map(|v| v.clamp(0.0, 1.0))
}
}
/// [`Lut3d::eval`], written as the shader samples the LUT (the same products and sums).
pub fn lut_eval(l: &Lut3d, c: [f32; 3]) -> [f32; 3] {
let n = l.size.max(2);
let mut k = [0usize; 3];
let mut f = [0f32; 3];
for i in 0..3 {
let x = c[i].clamp(0.0, 1.0) * (n - 1) as f32;
k[i] = (x.floor() as usize).min(n - 2);
f[i] = x - k[i] as f32;
}
let at = |r: usize, g: usize, b: usize| l.data.get((r * n + g) * n + b).copied().unwrap_or_default();
let mut out = [0f32; 3];
for corner in 0..8 {
let (dr, dg, db) = (corner >> 2 & 1, corner >> 1 & 1, corner & 1);
let w = (if dr == 1 { f[0] } else { 1.0 - f[0] }) * (if dg == 1 { f[1] } else { 1.0 - f[1] }) * (if db == 1 { f[2] } else { 1.0 - f[2] });
let v = at(k[0] + dr, k[1] + dg, k[2] + db);
for i in 0..3 {
out[i] += w * v[i];
}
}
out
}
impl DisplayColor {
/// The conversion as data for the display pass.
pub fn pass(&self) -> PassColor {
if let Some(l) = &self.lut {
return PassColor::Lut(l.clone());
}
match (&self.sim, &self.display) {
(Some((to, show)), _) => PassColor::Convs { first: PassConv::of(to), quantize: true, second: show.as_ref().map(PassConv::of) },
(None, Some(d)) => PassColor::Convs { first: PassConv::of(d), quantize: false, second: None },
(None, None) => {
PassColor::Convs { first: PassConv { from: PassCurve::Linear, matrix: None, to: PassCurve::Linear }, quantize: false, second: None }
}
}
}
}
/// How a rendered frame's pixels are encoded: the curve of its output space, and the HDR step
/// (Project Settings ▸ HDR) it was rendered without and the display pass still runs (`Clip`:
/// none). Kept with the frame, so a later settings change doesn't reinterpret older frames.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameEncoding {
pub curve: PassCurve,
pub hdr: HdrMode,
}
impl FrameEncoding {
/// A frame of a project with `settings`; `skipped_hdr_step`: rendered with
/// `RenderOpts::skip_hdr_step`.
pub fn of(settings: &ProjectSettings, skipped_hdr_step: bool) -> FrameEncoding {
let pipe = Pipe::of(settings);
let curve = pipe.output_linear().decode.map_or(PassCurve::Linear, |s| PassCurve::of(s.curve()));
let hdr = if skipped_hdr_step && pipe.output_hdr().is_some() { settings.hdr } else { HdrMode::Clip };
FrameEncoding { curve, hdr }
}
/// The frame as it would have been rendered with the HDR step: one straight colour.
pub fn finish(&self, c: [f32; 3]) -> [f32; 3] {
if self.hdr == HdrMode::Clip {
return c;
}
let l = hdr_step(self.hdr, c.map(|v| self.curve.decode(v)));
l.map(|v| self.curve.encode(v))
}
}
/// Project Settings ▸ HDR on linear light (`effectcraft_render::color::output_hdr`).
pub fn hdr_step(mode: HdrMode, l: [f32; 3]) -> [f32; 3] {
match mode {
HdrMode::Clip => l,
HdrMode::Compand => l.map(effectcraft_render::color::compand),
HdrMode::ToneMap => effectcraft_render::color::tone_map(l),
}
}
/// The whole display pass of one viewer: Show Channel, exposure, the frame's encoding and the
/// display colour conversion.
#[derive(Clone, Debug)]
pub struct DisplayPass {
pub channel: Channel,
pub colorized: bool,
/// 2^stops of the viewer's exposure.
pub gain: f32,
pub encoding: FrameEncoding,
pub color: Option<PassColor>,
}
impl DisplayPass {
/// `stops` of exposure on frames encoded as `encoding`.
pub fn new(channel: Channel, colorized: bool, stops: f32, encoding: FrameEncoding, color: Option<&DisplayColor>) -> DisplayPass {
let gain = if stops == 0.0 || !stops.is_finite() { 1.0 } else { 2f32.powf(stops) };
// The display conversion applies to the colour views only (as before).
let color = color.filter(|_| matches!(channel, Channel::Rgb | Channel::RgbStraight)).map(DisplayColor::pass);
DisplayPass { channel, colorized, gain, encoding, color }
}
/// Step 2 of the module docs runs.
pub fn changes_light(&self) -> bool {
self.gain != 1.0 || self.encoding.hdr != HdrMode::Clip
}
/// Nothing changes the frame's pixels (beyond clamping and rounding).
pub fn is_identity(&self) -> bool {
self.channel == Channel::Rgb && !self.changes_light() && self.color.is_none()
}
/// Step 2: exposure and the pending HDR step on straight colour.
fn light(&self, c: [f32; 3]) -> [f32; 3] {
if !self.changes_light() {
return c;
}
let curve = self.encoding.curve;
let l = hdr_step(self.encoding.hdr, c.map(|v| curve.decode(v) * self.gain));
l.map(|v| curve.encode(v))
}
/// One premultiplied pixel (encoded in the output space) → premultiplied RGBA8 on screen.
pub fn pixel(&self, p: [f32; 4]) -> [u8; 4] {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8;
let a = if p[3].is_nan() { 0.0 } else { p[3].clamp(0.0, 1.0) };
let un = |v: f32| if a > 0.0 && !v.is_nan() { v / a } else { 0.0 };
let c = self.light([un(p[0]), un(p[1]), un(p[2])]).map(|v| v.clamp(0.0, 1.0));
// A premultiplied channel: without exposure or HDR step straight from the frame (clamped
// to alpha, as `display_pixel`), so nothing changes it by a division's rounding.
let premul = |i: usize| if self.changes_light() || p[i].is_nan() { c[i] * a } else { p[i].clamp(0.0, a) };
match self.channel {
Channel::Rgb | Channel::RgbStraight => {
let c = match &self.color {
Some(dc) => dc.apply(c),
None => c,
};
if self.channel == Channel::RgbStraight {
[q(c[0]), q(c[1]), q(c[2]), 255]
} else if self.color.is_none() {
[q(premul(0)), q(premul(1)), q(premul(2)), q(a)]
} else {
[q(c[0] * a), q(c[1] * a), q(c[2] * a), q(a)]
}
}
Channel::Red | Channel::Green | Channel::Blue => {
let i = match self.channel {
Channel::Red => 0,
Channel::Green => 1,
_ => 2,
};
let v = q(premul(i));
if self.colorized {
let mut o = [0, 0, 0, 255];
o[i] = v;
o
} else {
[v, v, v, 255]
}
}
Channel::Alpha => {
// Exposure lightens or darkens the alpha view as an encoded grey.
let v = if self.gain != 1.0 { self.encoding.curve.encode(self.encoding.curve.decode(a) * self.gain) } else { a };
let v = q(v);
[v, v, v, 255]
}
}
}
/// The pass on premultiplied 8-bit pixels in place (the CPU path for 8-bit frames). Without
/// a display conversion or HDR step each channel of an opaque pixel goes through the same
/// curve on its own, so those pixels (and the Alpha view, which reads alpha only) look the
/// result up in a table of [`DisplayPass::pixel`] (the browser has no threads to share
/// the per-pixel work).
pub fn apply_rgba8(&self, px: &mut [[u8; 4]]) {
use rayon::prelude::*;
if self.is_identity() {
return;
}
let f = |v: u8| v as f32 / 255.0;
if self.channel == Channel::Alpha {
let t: [[u8; 4]; 256] = std::array::from_fn(|a| self.pixel([0.0, 0.0, 0.0, f(a as u8)]));
px.par_iter_mut().for_each(|p| *p = t[p[3] as usize]);
return;
}
let table = (self.color.is_none() && self.encoding.hdr == HdrMode::Clip)
.then(|| -> [[u8; 4]; 256] { std::array::from_fn(|v| self.pixel([f(v as u8), f(v as u8), f(v as u8), 1.0])) });
px.par_iter_mut().for_each(|p| {
*p = match &table {
Some(t) if p[3] == 255 => match self.channel {
Channel::Rgb | Channel::RgbStraight => [t[p[0] as usize][0], t[p[1] as usize][1], t[p[2] as usize][2], 255],
Channel::Red => t[p[0] as usize],
Channel::Green => t[p[1] as usize],
_ => t[p[2] as usize],
},
_ => self.pixel(p.map(f)),
}
});
}
}