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
Generated
+3
View File
@@ -1970,6 +1970,7 @@ dependencies = [
name = "effectcraft-ui-egui"
version = "0.21.0"
dependencies = [
"bytemuck",
"chrono",
"craft-quit",
"craft-ui",
@@ -1992,6 +1993,7 @@ dependencies = [
"egui_extras",
"egui_kittest",
"exr",
"half",
"image",
"kurbo",
"log",
@@ -2000,6 +2002,7 @@ dependencies = [
"serde",
"serde_json",
"sys-locale",
"tempfile",
"toml",
"web-time",
]
+1
View File
@@ -112,6 +112,7 @@ filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
bytemuck = "1"
pollster = "1"
half = "2.7"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
+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)),
}
});
}
}
+164 -15
View File
@@ -6,7 +6,9 @@
//! files' size and modification time included) plus frame, scale and view; layer buffers by
//! the layer cache's content key salted with the project's footage fingerprint. A changed
//! project therefore never reads stale pixels: its keys simply change.
//! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA, layer
//! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA (or, for
//! 32 bpc viewer frames, half-float RGBA with the bytes shuffled into planes; the entry
//! header's format byte tells them apart, 8-bit entries keep format 0), layer
//! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh
//! renders are bit-identical), Roto Brush / Object Matte segmentations as
//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are
@@ -76,6 +78,19 @@ pub struct Frame8 {
pub rgba: Vec<u8>,
}
/// A cached 32 bpc viewer frame: premultiplied RGBA half floats (IEEE binary16 bits), four
/// per pixel.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrameF16 {
pub width: u32,
pub height: u32,
pub rgba: Vec<u16>,
}
/// The entry header's format byte of frame entries.
const FORMAT_RGBA8: u8 = 0;
const FORMAT_F16: u8 = 1;
/// Counters for tests, the Info panel and `cache.diskStats`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DiskStats {
@@ -226,7 +241,8 @@ impl LayerPrefetch {
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
enum Job {
Write(Kind, u128, Vec<u8>),
/// Kind, key, header format byte, payload.
Write(Kind, u128, u8, Vec<u8>),
}
struct Queue {
@@ -245,7 +261,7 @@ impl Queue {
#[cfg(not(target_arch = "wasm32"))]
fn pop(&mut self) -> Option<Job> {
if let Some(j) = self.frames.pop_front() {
let Job::Write(_, _, p) = &j;
let Job::Write(_, _, _, p) = &j;
self.frame_bytes = self.frame_bytes.saturating_sub(p.len());
return Some(j);
}
@@ -520,16 +536,21 @@ impl DiskCache {
}
fn read(&self, kind: Kind, key: u128) -> Option<Vec<u8>> {
self.read_entry(kind, key).map(|(_, p)| p)
}
/// An entry's payload and its header's format byte.
fn read_entry(&self, kind: Kind, key: u128) -> Option<(u8, Vec<u8>)> {
if !self.contains(kind, key) {
self.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
let path = self.path(kind, key);
let data = std::fs::read(&path).ok();
let ok = data.as_deref().and_then(|d| verify(d, kind));
let ok = data.as_deref().and_then(|d| verify(d, kind).map(|p| (d[5], p)));
match ok {
Some(payload) => {
let payload = payload.to_vec();
Some((format, payload)) => {
let payload = (format, payload.to_vec());
self.hits.fetch_add(1, Ordering::Relaxed);
if let Ok(mut g) = self.index.lock() {
g.touch(kind, key);
@@ -552,6 +573,12 @@ impl DiskCache {
}
}
/// A valid file whose payload does not decode: delete it, as a damaged one.
fn discard(&self, kind: Kind, key: u128) {
let _ = std::fs::remove_file(self.path(kind, key));
self.forget(kind, key);
}
fn forget(&self, kind: Kind, key: u128) {
if let Ok(mut g) = self.index.lock() {
g.remove(kind, key);
@@ -560,9 +587,14 @@ impl DiskCache {
/// Queue a write; `false` when it was dropped (a full queue).
fn enqueue(&self, kind: Kind, key: u128, payload: Vec<u8>) -> bool {
self.enqueue_format(kind, key, FORMAT_RGBA8, payload)
}
/// [`DiskCache::enqueue`] with the entry's header format byte.
fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Vec<u8>) -> bool {
#[cfg(target_arch = "wasm32")]
{
self.write_now(kind, key, &payload);
self.write_format(kind, key, format, &payload);
true
}
#[cfg(not(target_arch = "wasm32"))]
@@ -580,9 +612,9 @@ impl DiskCache {
if kind == Kind::Frame {
q.pending_frames.insert(key);
q.frame_bytes = q.frame_bytes.saturating_add(payload.len());
q.frames.push_back(Job::Write(kind, key, payload));
q.frames.push_back(Job::Write(kind, key, format, payload));
} else {
q.jobs.push_back(Job::Write(kind, key, payload));
q.jobs.push_back(Job::Write(kind, key, format, payload));
}
cv.notify_all();
true
@@ -591,6 +623,10 @@ impl DiskCache {
/// Write an entry now (atomic rename), then evict down to the limit.
fn write_now(&self, kind: Kind, key: u128, payload: &[u8]) {
self.write_format(kind, key, FORMAT_RGBA8, payload);
}
fn write_format(&self, kind: Kind, key: u128, format: u8, payload: &[u8]) {
if self.contains(kind, key) {
return;
}
@@ -600,7 +636,7 @@ impl DiskCache {
self.write_failed(&e);
return;
}
let data = seal(kind, payload);
let data = seal_format(kind, format, payload);
// An entry that cannot fit would be evicted straight away.
if data.len() as u64 > self.limit_bytes() {
return;
@@ -646,11 +682,43 @@ impl DiskCache {
// ------------------------------------------------------------ typed entries
/// An 8-bit frame (`None` for a half-float entry of that key, which [`DiskCache::get_frame_f16`]
/// reads).
pub fn get_frame(&self, key: u128) -> Option<Frame8> {
let p = self.read(Kind::Frame, key)?;
let (format, p) = self.read_entry(Kind::Frame, key)?;
if format != FORMAT_RGBA8 {
return None;
}
decode_frame(&p)
}
/// A half-float frame (32 bpc viewer frames). A damaged one is deleted.
pub fn get_frame_f16(&self, key: u128) -> Option<FrameF16> {
let (format, p) = self.read_entry(Kind::Frame, key)?;
if format != FORMAT_F16 {
return None;
}
let f = decode_frame_f16(&p);
if f.is_none() {
self.discard(Kind::Frame, key);
}
f
}
/// Queue a half-float frame for writing (`width * height * 4` binary16 values). `true` when
/// it is on disk or queued; `false` when refused (wrong size, a full queue).
pub fn put_frame_f16(&self, key: u128, width: u32, height: u32, rgba: &[u16]) -> bool {
if rgba.len() as u64 != width as u64 * height as u64 * 4 {
return false;
}
self.contains(Kind::Frame, key) || self.enqueue_format(Kind::Frame, key, FORMAT_F16, encode_frame_f16(width, height, rgba))
}
/// Write a half-float frame synchronously (tests).
pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16]) {
self.write_format(Kind::Frame, key, FORMAT_F16, &encode_frame_f16(width, height, rgba));
}
/// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). `true`
/// when it is on disk or queued; `false` when refused (wrong size, a full queue).
pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) -> bool {
@@ -762,8 +830,8 @@ fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
}
};
let Some(cache) = dc.upgrade() else { return };
let Job::Write(kind, key, payload) = job;
cache.write_now(kind, key, &payload);
let Job::Write(kind, key, format, payload) = job;
cache.write_format(kind, key, format, &payload);
drop(cache);
if let Ok(mut g) = m.lock() {
g.busy = false;
@@ -775,12 +843,17 @@ fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
}
}
/// An entry file's bytes: header (magic, kind), payload, checksum.
/// An entry file's bytes: header (magic, kind, format 0), payload, checksum.
pub fn seal(kind: Kind, payload: &[u8]) -> Vec<u8> {
seal_format(kind, FORMAT_RGBA8, payload)
}
/// [`seal`] with the header's format byte (frames: 0 = 8-bit, 1 = half float).
fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec<u8> {
let mut data = Vec::with_capacity(payload.len() + 16);
data.extend_from_slice(MAGIC);
data.push(kind.tag());
data.extend_from_slice(&[0, 0, 0]);
data.extend_from_slice(&[format, 0, 0]);
data.extend_from_slice(payload);
let sum = checksum(&data);
data.extend_from_slice(&sum.to_le_bytes());
@@ -879,6 +952,46 @@ fn decode_frame(p: &[u8]) -> Option<Frame8> {
(rgba.len() == w as usize * h as usize * 4).then_some(Frame8 { width: w, height: h, rgba })
}
/// Half floats with their low and high bytes in planes (per channel), then LZ4: the high bytes
/// (sign, exponent) repeat a lot, mixed with the low ones LZ4 finds little.
fn encode_frame_f16(w: u32, h: u32, rgba: &[u16]) -> Vec<u8> {
use rayon::prelude::*;
let n = rgba.len() / 4;
let mut planes = vec![0u8; n * 8];
// Plane (byte k, channel c) at (k · 4 + c) · n.
planes.par_chunks_mut(n.max(1)).enumerate().for_each(|(plane, out)| {
let (k, c) = (plane / 4, plane % 4);
for (o, px) in out.iter_mut().zip(rgba.as_chunks::<4>().0) {
*o = px[c].to_le_bytes()[k];
}
});
let mut out = Vec::with_capacity(planes.len() / 3 + 16);
out.extend_from_slice(&w.to_le_bytes());
out.extend_from_slice(&h.to_le_bytes());
out.extend_from_slice(&lz4_flex::compress_prepend_size(&planes));
out
}
fn decode_frame_f16(p: &[u8]) -> Option<FrameF16> {
if p.len() < 8 {
return None;
}
let w = u32::from_le_bytes(p[0..4].try_into().ok()?);
let h = u32::from_le_bytes(p[4..8].try_into().ok()?);
let n = (w as usize).checked_mul(h as usize)?;
let planes = lz4_flex::decompress_size_prepended(&p[8..]).ok()?;
if planes.len() != n.checked_mul(8)? {
return None;
}
let mut rgba = vec![0u16; n * 4];
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
for (c, v) in px.iter_mut().enumerate() {
*v = u16::from_le_bytes([planes[c * n + i], planes[(4 + c) * n + i]]);
}
}
Some(FrameF16 { width: w, height: h, rgba })
}
fn encode_layer(b: &Buf) -> Vec<u8> {
let (w, h) = (b.img.width, b.img.height);
let n = b.img.data.len();
@@ -1175,6 +1288,42 @@ mod tests {
assert_eq!(parse_entry_name(&format!("{key:032x}.tmp.1")), None);
}
/// Half-float frames (32 bpc viewer frames) round-trip bit for bit, over-range values,
/// negatives and odd sizes included; they and 8-bit frames never read as each other; a
/// damaged half-float entry is a miss and is deleted; they count toward the size limit.
#[test]
fn half_float_frames_round_trip() {
let dir = tmpdir("f16");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
let (w, h) = (7u32, 5u32);
// 4.0, 16.0, -0.25, 65504 and a spread of bit patterns.
let rgba: Vec<u16> = (0..w * h * 4).map(|i| [0x4400, 0x4c00, 0xb400, 0x7bff][i as usize % 4] ^ ((i * 37) as u16 & 0x03ff)).collect();
dc.put_frame_f16_now(1, w, h, &rgba);
assert_eq!(dc.get_frame_f16(1), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() }));
assert!(dc.get_frame(1).is_none(), "not an 8-bit frame");
dc.put_frame_now(2, 2, 2, &[9; 16]);
assert!(dc.get_frame_f16(2).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid");
assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]);
assert!(dc.stats().bytes > 0);
// Reopened, still there (format byte persisted).
drop(dc);
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
assert_eq!(dc.get_frame_f16(1).unwrap().rgba, rgba);
// A sealed entry with a payload of the wrong size: deleted on read.
let p = dc.path(Kind::Frame, 3);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16, &encode_frame_f16(w, h + 1, &rgba[..])[..])).unwrap();
dc.rescan();
assert!(dc.contains(Kind::Frame, 3));
assert!(dc.get_frame_f16(3).is_none());
assert!(!dc.contains(Kind::Frame, 3) && !p.exists());
// Wrong lengths are not written.
dc.put_frame_f16(4, w, h, &rgba[1..]);
dc.flush();
assert!(!dc.contains(Kind::Frame, 4));
let _ = std::fs::remove_dir_all(&dir);
}
/// Segmentations round-trip by (chain key, scale), survive a reopen (rescan), are bounded by
/// the size limit like every entry, and a damaged file is a miss.
#[test]
+6
View File
@@ -33,11 +33,16 @@ rayon = { workspace = true }
web-time = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
# Half-float viewer frames of 32 bpc projects (F16C is picked at run time).
half = { workspace = true }
bytemuck = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in OS windows of their own on desktop; the web keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
craft-update = { workspace = true }
# Waits for the display pass's error scopes (immediately ready on native wgpu).
pollster = { workspace = true }
sys-locale = { workspace = true }
[dev-dependencies]
@@ -50,6 +55,7 @@ effectcraft-segment = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-media = { workspace = true }
exr = { workspace = true }
tempfile = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
+7
View File
@@ -79,6 +79,13 @@ impl EffectcraftApp {
viewer_shown: None,
crypto_probe_busy: Default::default(),
viewer_image: None,
viewer_f16: None,
window_wgpu: None,
display_gpu: None,
display_gpu_failed: false,
viewer_bake: Default::default(),
snapshot_bake: Default::default(),
snapshot_f16: None,
viewer_readback: Default::default(),
wgpu: None,
gpu: None,
+22 -3
View File
@@ -73,14 +73,30 @@ impl EffectcraftApp {
renderer.free_texture(id);
}
for (_, texture) in self.passive_tex.values() {
if let panels::viewers::PassiveTexture::Gpu(id, _) = texture {
renderer.free_texture(id);
match texture {
panels::viewers::PassiveTexture::Gpu(id, _) => renderer.free_texture(id),
panels::viewers::PassiveTexture::Baked(b) => {
if let Some(id) = b.id() {
renderer.free_texture(&id);
}
}
panels::viewers::PassiveTexture::Cpu(_) => {}
}
}
drop(renderer);
}
// The display pass's textures: half-float frames go through the CPU from now on.
self.viewer_bake.free(self.window_wgpu.as_ref());
self.snapshot_bake.free(self.window_wgpu.as_ref());
self.display_gpu = None;
self.window_wgpu = None;
self.viewer_native = None;
self.passive_tex.retain(|_, (_, texture)| matches!(texture, panels::viewers::PassiveTexture::Cpu(_)));
self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key, _)| (texture.id(), *key));
self.viewer_shown = match &self.viewer_f16 {
// A half-float frame stays on screen through the CPU pass.
Some(_) => self.viewer_shown.map(|(_, key)| (None, key)),
None => self.viewer_tex.as_ref().map(|(texture, key, _)| (Some(texture.id()), *key)),
};
self.viewer_image = None;
// Old completion closures retain only their detached slot, not the replacement.
self.viewer_readback = Default::default();
@@ -152,6 +168,9 @@ impl EffectcraftApp {
return;
}
let Some(rs) = frame.wgpu_render_state() else { return };
// The display pass needs only this device, not the compositor (which may take tens of
// seconds to build, or fail).
self.window_wgpu = Some(rs.clone());
// Help ▸ System Compatibility Report names the adapter and backend the window got.
let info = rs.adapter.get_info();
self.session.window_adapter = Some(format!(
+4 -6
View File
@@ -221,10 +221,7 @@ impl EffectcraftApp {
self.playback.start_frame = cur;
// `prefetch_playback` requests every uncached frame of offsets 0..window from here.
let window = (ahead * 3).min(fit - 1);
let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| {
let [w, h] = img.size();
startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4))
});
let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| startup_frames(ahead, window, self.frames.budget(), img.bytes()));
if need.is_some() && self.playback.first_cached.is_none() {
self.playback.first_cached = Some(now);
}
@@ -448,10 +445,11 @@ impl EffectcraftApp {
((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1)
}
/// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger).
/// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger; 32 bpc
/// frames are half floats, twice the bytes of 8-bit ones).
fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 {
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
side(comp.width) * side(comp.height) * 4.0
side(comp.width) * side(comp.height) * frames::frame_pixel_bytes(&self.session.project.settings) as f64
}
fn frames_parallelism(&self) -> usize {
+41 -2
View File
@@ -7,11 +7,18 @@ impl EffectcraftApp {
}
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
/// first time something asks for it.
/// first time something asks for it. A half-float frame (32 bpc) is converted the first time:
/// as rendered with Project Settings ▸ HDR, without the viewer's exposure or display colour.
///
/// The browser can't wait for the GPU: there the first call starts an asynchronous readback
/// and returns `None`; a later call (the next frame) has the pixels.
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
if self.viewer_image.is_none()
&& let Some(f) = &self.viewer_f16
{
let pass = effectcraft_engine::viewer::DisplayPass::new(effectcraft_engine::viewer::Channel::Rgb, false, 0.0, f.encoding, None);
self.viewer_image = Some(Arc::new(f.to_color_image(&pass)));
}
let to_image = |bytes: Vec<u8>, w: u32, h: u32| {
let px = bytes.as_chunks::<4>().0.iter().map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect();
Arc::new(egui::ColorImage::new([w as usize, h as usize], px))
@@ -44,6 +51,36 @@ impl EffectcraftApp {
self.viewer_image.clone()
}
/// The half-float frame on screen (32 bpc projects), for float readouts.
pub fn viewer_f16(&self) -> Option<&Arc<frames::F16Frame>> {
self.viewer_f16.as_ref()
}
/// The texture the Composition viewer's display pass drew last (half-float frames, or 8-bit
/// frames with exposure, a channel view or display colour), when it ran on the GPU.
pub fn viewer_display_texture(&self) -> Option<egui::TextureId> {
self.viewer_bake.id()
}
/// The display pass's pipelines on egui-wgpu's device, made the first time (desktop; the
/// browser's viewer converts on the CPU for now). `None` without a device.
pub(crate) fn display_gpu(&mut self) -> Option<Arc<crate::display_gpu::DisplayGpu>> {
if cfg!(target_arch = "wasm32") || self.display_gpu_failed {
return None;
}
if self.display_gpu.is_none() {
let rs = self.window_wgpu.as_ref()?;
match crate::display_gpu::DisplayGpu::try_new(&rs.device) {
Ok(g) => self.display_gpu = Some(Arc::new(g)),
Err(e) => {
log::warn!("the viewer's display pass runs on the CPU: {e}");
self.display_gpu_failed = true;
}
}
}
self.display_gpu.clone()
}
/// The viewer's Resolution: the active composition's (saved with the project).
pub fn viewer_res(&self) -> state::Resolution {
self.session.active_comp().map(|c| c.resolution).unwrap_or_default()
@@ -173,7 +210,9 @@ impl EffectcraftApp {
backend: effectcraft_engine::render::Backend::Auto,
nested_switches: self.session.prefs.general.switches_affect_nested_comps,
draft_shadows: self.session.prefs.three_d.realtime_shadows,
skip_hdr_step: false,
// 32 bpc: half-float frames without the HDR step; the display pass applies it after
// the viewer's exposure (#2797).
skip_hdr_step: frames::f16_frames(&self.session.project.settings),
proxy: Default::default(),
}
}
+90 -1
View File
@@ -710,7 +710,8 @@ mod tests {
options.exposure = 1.0;
let exposed = press_sampling_viewer(&mut harness).unwrap();
let mut expected = [[32, 64, 96, 255]];
effectcraft_engine::viewer::display_transform(&mut expected, effectcraft_engine::viewer::Channel::Rgb, false, 1.0);
let encoding = effectcraft_engine::viewer::FrameEncoding::of(&harness.state().session.project.settings, false);
effectcraft_engine::viewer::DisplayPass::new(effectcraft_engine::viewer::Channel::Rgb, false, 1.0, encoding, None).apply_rgba8(&mut expected);
for (actual, expected) in exposed.iter().zip(expected[0]) {
assert!((*actual - expected as f32 / 255.0).abs() < 0.01, "{exposed:?}");
}
@@ -736,6 +737,94 @@ mod tests {
assert_eq!(press_sampling_viewer(&mut harness), None, "wireframe must not sample an old pixel frame");
}
/// A 32 bpc project whose solid is about 4.0 in linear light (white +2 stops), in half-float viewer
/// frames; `hdr`: Project Settings ▸ HDR (with an sRGB working space).
fn hdr_viewer(hdr: &str) -> egui_kittest::Harness<'static, crate::EffectcraftApp> {
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", serde_json::json!({"width": 320, "height": 180, "duration": 2})).unwrap();
s.execute("file.projectSettings", serde_json::json!({"bitDepth": 32, "workingSpace": "srgb", "hdr": hdr})).unwrap();
let lid = s.execute("layer.newSolid", serde_json::json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", serde_json::json!({"layers": [lid], "effect": "Exposure"})).unwrap();
s.execute("prop.set", serde_json::json!({"layer": lid, "path": "effects/#1/master/exposure", "value": 2.0})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(s));
for _ in 0..400 {
harness.step();
if harness.state().frames.inflight() == 0 && harness.state().viewer_f16.is_some() {
harness.run_steps(3);
return harness;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the half-float viewer frame did not arrive");
}
/// The linear light of the frame's first pixel (sRGB-encoded).
fn linear_of(harness: &egui_kittest::Harness<'_, crate::EffectcraftApp>) -> f32 {
let p = harness.state().viewer_f16.as_ref().unwrap().pixel(0).unwrap();
effectcraft_engine::color::srgb_to_linear(p[0] / p[3])
}
fn exposed(harness: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>, stops: f32) -> f32 {
harness.state_mut().session.state.viewer.exposure = stops;
harness.run_steps(2);
press_sampling_viewer(harness).unwrap()[0]
}
/// #2797: in 32 bpc the viewer's exposure reaches values above 1.0. White +2 stops (about 4.0)
/// is white at 0 EV and still white at -2 EV (the 8-bit viewer showed 0.25 there), and at -3 EV
/// it is an eighth of its linear light.
#[test]
fn viewer_exposure_reaches_over_range_values_in_32_bpc() {
let mut harness = hdr_viewer("clip");
let l = linear_of(&harness);
assert!(l > 3.9, "the frame keeps the over-range value: {l}");
let srgb = |l: f32| effectcraft_engine::color::linear_to_srgb(l);
assert!((exposed(&mut harness, 0.0) - 1.0).abs() < 0.01);
assert!((exposed(&mut harness, -2.0) - 1.0).abs() < 0.01, "-2 EV");
let v = exposed(&mut harness, -3.0);
assert!((v - srgb(l / 8.0)).abs() < 0.01 && v < 0.8, "-3 EV: {v}");
// The 8-bit image other panels read: as rendered (clipped), without the exposure.
let img = harness.state_mut().viewer_pixels().unwrap();
assert_eq!(img.pixels[0].to_array(), [255, 255, 255, 255]);
// A 32 bpc snapshot keeps the range too.
harness.state_mut().session.execute("view.takeSnapshot", serde_json::json!({})).unwrap();
harness.state_mut().session.state.viewer.show_snapshot = true;
let v = exposed(&mut harness, -3.0);
assert!((v - srgb(l / 8.0)).abs() < 0.01, "snapshot at -3 EV: {v}");
}
/// Compand runs after the exposure: at -3 EV the 4.0 white is about 0.5, under the knee, so it
/// shows as that (companding first left 0.99, darkened to 0.12). At 0 EV it is companded as
/// before.
#[test]
fn compand_follows_the_viewer_exposure() {
let mut harness = hdr_viewer("compand");
assert_eq!(harness.state().viewer_f16.as_ref().unwrap().encoding.hdr, effectcraft_engine::project::HdrMode::Compand);
let srgb = |l: f32| effectcraft_engine::color::linear_to_srgb(l);
let l = linear_of(&harness);
let at0 = exposed(&mut harness, 0.0);
let want = srgb(effectcraft_engine::render::color::compand(l));
assert!((at0 - want).abs() < 0.01, "0 EV: {at0} vs {want}");
let v = exposed(&mut harness, -3.0);
assert!((v - srgb(l / 8.0)).abs() < 0.01 && v < 0.8, "-3 EV: {v}");
// Other panels' 8-bit image is the companded frame, as the render was.
let img = harness.state_mut().viewer_pixels().unwrap();
assert!((img.pixels[0].r() as f32 / 255.0 - want).abs() < 0.01);
// A passive viewer gets the half-float frame through the same pass (no channel or exposure
// of its own; on the CPU without a device): the companded frame.
let ctx = harness.ctx.clone();
let (key, f) = (harness.state().viewer_shown.unwrap().1, harness.state().viewer_f16.clone().unwrap());
crate::panels::viewers::set_texture(harness.state_mut(), &ctx, 7, key, crate::frames::FrameImage::CpuF16(f), None, 0);
let Some((_, crate::panels::viewers::PassiveTexture::Cpu(tex))) = harness.state().passive_tex.get(&7) else { panic!("no passive texture") };
assert_eq!(tex.size(), [320, 180]);
let f = harness.state().viewer_f16.clone().unwrap();
let crate::frames::FrameImage::Cpu(shown) = crate::panels::viewers::cpu_passive_image(harness.state(), crate::frames::FrameImage::CpuF16(f), None)
else {
panic!("an 8-bit image")
};
assert!((shown.pixels[0].r() as f32 / 255.0 - want).abs() < 0.01, "the passive viewer shows the companded frame: {:?}", shown.pixels[0]);
}
#[test]
fn a_new_modal_backdrop_cannot_deliver_a_pending_sample() {
let mut harness = sampling_viewer();
+492
View File
@@ -0,0 +1,492 @@
//! The viewer's display pass on egui-wgpu's device (#2797): a rendered frame (half floats for 32
//! bpc projects, RGBA8 otherwise) goes through Show Channel, exposure, the pending HDR step and
//! the display colour (`effectcraft_engine::viewer::DisplayPass`) into one premultiplied RGBA8
//! texture with mips per viewer, which egui draws like the GPU compositor's frames (native
//! texture, the Viewer Zoom Quality sampler).
//!
//! This is not the compositor's `Gpu` (which may be missing): it needs only egui-wgpu's device.
//! Without one (no adapter, headless tests, the device retired) the same pass runs on the CPU
//! (`DisplayPass::pixel`). The shader (`display_gpu.wgsl`) repeats the CPU steps operation by
//! operation; the tests below hold it to them.
use std::sync::Arc;
use effectcraft_engine::viewer::{Channel, DisplayPass, PassColor, PassConv, PassCurve};
use eframe::egui_wgpu;
use eframe::wgpu;
use crate::frames::F16Frame;
const UNIFORM_BYTES: usize = 12 * 16;
/// A frame for the display pass.
#[derive(Clone)]
pub enum Source {
F16(Arc<F16Frame>),
Rgba8(Arc<egui::ColorImage>),
}
impl Source {
fn same(&self, o: &Source) -> bool {
match (self, o) {
(Source::F16(a), Source::F16(b)) => Arc::ptr_eq(a, b),
(Source::Rgba8(a), Source::Rgba8(b)) => Arc::ptr_eq(a, b),
_ => false,
}
}
pub fn size(&self) -> [usize; 2] {
match self {
Source::F16(f) => f.size(),
Source::Rgba8(c) => c.size,
}
}
/// A half-float frame past the texture limit goes through the CPU pass: the display
/// conversion (exposure, HDR) comes before the averaging down, as with the 8-bit mips, and
/// the GPU would average the scene-linear values first.
pub fn needs_cpu_pass(&self, max_side: usize) -> bool {
matches!(self, Source::F16(_)) && crate::frames::fit_factor(self.size(), max_side, 1) > 1
}
}
/// The pass's uniform block (`Params` in the shader).
pub fn uniform(pass: &DisplayPass) -> [u8; UNIFORM_BYTES] {
let channel = match pass.channel {
Channel::Rgb => 0u32,
Channel::Red => 1,
Channel::Green => 2,
Channel::Blue => 3,
Channel::Alpha => 4,
Channel::RgbStraight => 5,
};
let hdr = match pass.encoding.hdr {
effectcraft_engine::project::HdrMode::Clip => 0u32,
effectcraft_engine::project::HdrMode::Compand => 1,
effectcraft_engine::project::HdrMode::ToneMap => 2,
};
let curve = pass.encoding.curve;
let none = PassConv { from: PassCurve::Linear, matrix: None, to: PassCurve::Linear };
let (kind, quantize, first, second, lut_n) = match &pass.color {
None => (0u32, false, none, None, 2u32),
Some(PassColor::Convs { first, quantize, second }) => (1, *quantize, *first, *second, 2),
Some(PassColor::Lut(l)) => (2, false, none, None, l.size.max(2) as u32),
};
let s = second.unwrap_or(none);
let mat = |m: Option<[[f32; 3]; 3]>| {
let m = m.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
m.map(|r| [r[0], r[1], r[2], 0.0])
};
let [x24, xm2, xm1] = effectcraft_engine::viewer::SHADER_EXPONENTS;
let u = |v: u32| v.to_le_bytes();
let f = |v: f32| v.to_le_bytes();
let mut b = Vec::with_capacity(UNIFORM_BYTES);
for v in [
channel,
pass.colorized as u32,
curve.id(),
hdr,
kind,
quantize as u32,
second.is_some() as u32,
lut_n,
first.from.id(),
first.to.id(),
s.from.id(),
s.to.id(),
] {
b.extend(u(v));
}
for v in [
pass.gain,
curve.decode_param(),
first.matrix.is_some() as u32 as f32,
s.matrix.is_some() as u32 as f32,
first.from.decode_param(),
first.to.encode_param(),
s.from.decode_param(),
s.to.encode_param(),
curve.encode_param(),
x24,
xm2,
xm1,
] {
b.extend(f(v));
}
for row in mat(first.matrix).into_iter().chain(mat(s.matrix)) {
for v in row {
b.extend(f(v));
}
}
let mut out = [0u8; UNIFORM_BYTES];
out.copy_from_slice(&b);
out
}
/// Mip levels of a full chain for a `w` × `h` texture.
fn mip_levels(w: u32, h: u32) -> u32 {
32 - w.max(h).max(1).leading_zeros()
}
/// The display pass's pipelines on one device (made once, kept with the app).
pub struct DisplayGpu {
bake: wgpu::RenderPipeline,
mip: wgpu::RenderPipeline,
bake_layout: wgpu::BindGroupLayout,
mip_layout: wgpu::BindGroupLayout,
no_lut: wgpu::TextureView,
max_side: u32,
}
impl DisplayGpu {
/// The pipelines, or why the device would not make them (a shader a backend rejects): the
/// viewer then runs the pass on the CPU instead of showing nothing.
pub fn try_new(device: &wgpu::Device) -> Result<DisplayGpu, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| DisplayGpu::new(device)));
let validation = pollster::block_on(validation.pop());
let internal = pollster::block_on(internal.pop());
if let Some(e) = validation.or(internal) {
return Err(format!("the display pass's pipelines: {e}"));
}
made.map_err(|_| "the display pass's pipelines: the backend panicked".to_string())
}
#[cfg(target_arch = "wasm32")]
{
Ok(DisplayGpu::new(device))
}
}
pub fn new(device: &wgpu::Device) -> DisplayGpu {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("viewer display pass"),
source: wgpu::ShaderSource::Wgsl(include_str!("display_gpu.wgsl").into()),
});
let tex = |binding: u32, dim: wgpu::TextureViewDimension, filterable: bool| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable }, view_dimension: dim, multisampled: false },
count: None,
};
let uniform = wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
};
let bake_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("viewer display pass"),
entries: &[uniform, tex(1, wgpu::TextureViewDimension::D2, false), tex(2, wgpu::TextureViewDimension::D3, false)],
});
let mip_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("viewer display mips"),
entries: &[tex(1, wgpu::TextureViewDimension::D2, false)],
});
let pipeline = |label: &str, layout: &wgpu::BindGroupLayout, entry: &str| {
let pl =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some(label), bind_group_layouts: &[Some(layout)], immediate_size: 0 });
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pl),
vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState { format: wgpu::TextureFormat::Rgba8Unorm, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
};
let no_lut = device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer display LUT (none)"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
DisplayGpu {
bake: pipeline("viewer display pass", &bake_layout, "fs"),
mip: pipeline("viewer display mips", &mip_layout, "mip"),
bake_layout,
mip_layout,
no_lut: no_lut.create_view(&Default::default()),
max_side: device.limits().max_texture_dimension_2d,
}
}
/// The largest texture side the device takes.
pub fn max_side(&self) -> usize {
self.max_side as usize
}
/// `src` in a texture (half floats or RGBA8), averaged down by `k` first when `k > 1`
/// (8-bit frames past the texture limit, #201; half-float ones never get here, see
/// [`Source::needs_cpu_pass`]): `reuse` when it has the size and format (a playing
/// preview's frames), else a new one.
fn upload(&self, device: &wgpu::Device, queue: &wgpu::Queue, src: &Source, k: usize, reuse: Option<wgpu::Texture>) -> wgpu::Texture {
let write = |w: u32, h: u32, format: wgpu::TextureFormat, bytes: &[u8]| {
let (w, h) = (w.max(1), h.max(1));
let fits = |t: &wgpu::Texture| t.width() == w && t.height() == h && t.format() == format;
let tex = reuse.clone().filter(fits).unwrap_or_else(|| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer frame (display pass input)"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
});
let px = if format == wgpu::TextureFormat::Rgba16Float { 8 } else { 4 };
if bytes.len() as u64 >= w as u64 * h as u64 * px as u64 {
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytes,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * px), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
}
tex
};
let f16 = wgpu::TextureFormat::Rgba16Float;
let rgba8 = wgpu::TextureFormat::Rgba8Unorm;
match src {
Source::F16(f) => {
debug_assert_eq!(k, 1, "half-float frames past the texture limit take the CPU pass");
write(f.width, f.height, f16, bytemuck::cast_slice(f.bits()))
}
Source::Rgba8(c) if k > 1 => {
let c = crate::frames::fit_texture_by((**c).clone(), usize::MAX, k);
write(c.size[0] as u32, c.size[1] as u32, rgba8, c.as_raw())
}
Source::Rgba8(c) => write(c.size[0] as u32, c.size[1] as u32, rgba8, c.as_raw()),
}
}
fn lut(&self, device: &wgpu::Device, queue: &wgpu::Queue, lut: &effectcraft_engine::color::icc::Lut3d) -> wgpu::Texture {
let n = lut.size.max(2) as u32;
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer display LUT"),
size: wgpu::Extent3d { width: n, height: n, depth_or_array_layers: n },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// data[(r · n + g) · n + b]: x = b, y = g, z = r.
let mut texels: Vec<f32> = Vec::with_capacity((n * n * n * 4) as usize);
for i in 0..(n * n * n) as usize {
let v = lut.data.get(i).copied().unwrap_or_default();
texels.extend([v[0], v[1], v[2], 1.0]);
}
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(n * 16), rows_per_image: Some(n) },
wgpu::Extent3d { width: n, height: n, depth_or_array_layers: n },
);
tex
}
/// The display texture for `input`: premultiplied RGBA8, a full mip chain.
fn output(device: &wgpu::Device, w: u32, h: u32) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer frame (displayed)"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: mip_levels(w, h),
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
/// Run the pass from `input` into level 0 of `out`, then fill its mips.
fn run(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
input: &wgpu::Texture,
out: &wgpu::Texture,
params: &[u8; UNIFORM_BYTES],
lut: Option<&wgpu::Texture>,
) {
use wgpu::util::DeviceExt as _;
let buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("viewer display pass"),
contents: params,
usage: wgpu::BufferUsages::UNIFORM,
});
let input_view = input.create_view(&Default::default());
let lut_view = lut.map(|l| l.create_view(&Default::default()));
let bake = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer display pass"),
layout: &self.bake_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&input_view) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(lut_view.as_ref().unwrap_or(&self.no_lut)) },
],
});
let level = |l: u32| out.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() });
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("viewer display pass") });
let pass = |enc: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, pipeline: &wgpu::RenderPipeline, bg: &wgpu::BindGroup| {
let mut p = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("viewer display pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
..Default::default()
});
p.set_pipeline(pipeline);
p.set_bind_group(0, bg, &[]);
p.draw(0..3, 0..1);
};
pass(&mut enc, &level(0), &self.bake, &bake);
for l in 1..out.mip_level_count() {
let above = level(l - 1);
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer display mips"),
layout: &self.mip_layout,
entries: &[wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&above) }],
});
pass(&mut enc, &level(l), &self.mip, &bg);
}
queue.submit([enc.finish()]);
}
}
/// One viewer's displayed texture (the Composition viewer, a passive viewer, the snapshot): the
/// frame it was made from and the pass's uniform, so it runs again only when either changes.
#[derive(Default)]
pub struct Bake {
source: Option<Source>,
/// Averaged down by this for the texture limit (1: not).
factor: usize,
input: Option<wgpu::Texture>,
output: Option<wgpu::Texture>,
id: Option<egui::TextureId>,
params: Option<[u8; UNIFORM_BYTES]>,
lut: Option<(Arc<effectcraft_engine::color::icc::Lut3d>, wgpu::Texture)>,
smooth: Option<bool>,
/// How often the pass ran (tests).
runs: u64,
}
impl Bake {
/// The egui texture showing `src` through `pass` (sampled with the Viewer Zoom Quality's
/// sampler for `smooth`), running the pass again when the frame or the pass changed.
pub fn show(&mut self, rs: &egui_wgpu::RenderState, gpu: &DisplayGpu, src: &Source, pass: &DisplayPass, smooth: bool) -> Option<egui::TextureId> {
if src.needs_cpu_pass(gpu.max_side()) {
return None;
}
let (device, queue) = (&rs.device, &rs.queue);
let params = uniform(pass);
let new_source = !self.source.as_ref().is_some_and(|s| s.same(src));
if new_source {
self.factor = crate::frames::fit_factor(src.size(), gpu.max_side(), 1);
self.input = Some(gpu.upload(device, queue, src, self.factor, self.input.take()));
self.source = Some(src.clone());
}
let lut = match &pass.color {
Some(PassColor::Lut(l)) => Some(l.clone()),
_ => None,
};
// A new LUT (another profile, Preserve RGB, another display) has the same size, so the
// uniform alone doesn't tell: it reruns the pass itself.
let new_lut = match (&lut, &self.lut) {
(Some(l), Some((have, _))) if Arc::ptr_eq(l, have) => false,
(Some(l), _) => {
self.lut = Some((l.clone(), gpu.lut(device, queue, l)));
true
}
(None, had) => {
let changed = had.is_some();
self.lut = None;
changed
}
};
let input = self.input.as_ref()?;
let (w, h) = (input.width(), input.height());
let resized = self.output.as_ref().is_none_or(|o| o.width() != w || o.height() != h);
if resized {
self.output = Some(DisplayGpu::output(device, w, h));
}
let output = self.output.as_ref()?;
if new_source || resized || new_lut || self.params != Some(params) {
gpu.run(device, queue, input, output, &params, self.lut.as_ref().map(|(_, t)| t));
self.params = Some(params);
self.runs += 1;
}
let view = output.create_view(&Default::default());
let sampler = || crate::panels::viewer::zoom_sampler(smooth);
let mut ren = rs.renderer.write();
let id = match self.id {
Some(id) if !resized && self.smooth == Some(smooth) => id,
Some(id) => {
ren.update_egui_texture_from_wgpu_texture_with_sampler_options(device, &view, sampler(), id);
id
}
None => ren.register_native_texture_with_sampler_options(device, &view, sampler()),
};
self.id = Some(id);
self.smooth = Some(smooth);
Some(id)
}
/// The texture egui draws, once [`Bake::show`] made it.
pub fn id(&self) -> Option<egui::TextureId> {
self.id
}
/// The frame shown (the one last given to [`Bake::show`]).
pub fn source(&self) -> Option<&Source> {
self.source.as_ref()
}
/// The texture coordinates of the frame in its texture (see `frames::fitted_uv`).
pub fn uv(&self) -> egui::Rect {
let size = self.source.as_ref().map_or([1, 1], Source::size);
crate::frames::fitted_uv(size, self.factor.max(1))
}
/// Give the texture back to the renderer.
pub fn free(&mut self, rs: Option<&egui_wgpu::RenderState>) {
if let (Some(id), Some(rs)) = (self.id.take(), rs) {
rs.renderer.write().free_texture(&id);
}
*self = Bake::default();
}
/// How often the pass ran for this slot.
pub fn runs(&self) -> u64 {
self.runs
}
/// The displayed texture (tests read it back).
pub fn texture(&self) -> Option<&wgpu::Texture> {
self.output.as_ref()
}
}
#[cfg(test)]
mod tests;
+314
View File
@@ -0,0 +1,314 @@
// The viewer's display pass (#2797): a rendered frame (premultiplied, encoded in the output
// space; Rgba16Float for 32 bpc frames, Rgba8Unorm otherwise) → the premultiplied RGBA8 texture
// the viewer draws. Line by line the same operations as `effectcraft_engine::viewer::DisplayPass`
// (its `pixel` is this shader's CPU reference and fallback):
// unpremultiply → decode, × 2^stops, Compand / Tone Map, encode → clamp → display colour →
// Show Channel, premultiply → floor(v · 255 + 0.5).
struct Params {
// channel (0 RGB, 1 R, 2 G, 3 B, 4 A, 5 RGB Straight), colorized, frame curve, HDR step
// (0 none, 1 Compand, 2 Tone Map)
ids0: vec4<u32>,
// display colour (0 none, 1 conversions, 2 LUT), 8-bit device numbers, a second conversion,
// LUT size
ids1: vec4<u32>,
// curves: first conversion's decode and encode, second conversion's decode and encode
ids2: vec4<u32>,
// gain (2^stops), frame curve's gamma, first / second conversion has a matrix (1.0)
vals0: vec4<f32>,
// gammas of the ids2 curves (encoding: 1 / gamma)
vals1: vec4<f32>,
// 1 / the frame curve's gamma, 1 / 2.4, 1 / PQ m2, 1 / PQ m1: divided on the CPU, as the
// reference does, so the exponents are the same bits
vals2: vec4<f32>,
m1: array<vec4<f32>, 3>,
m2: array<vec4<f32>, 3>,
};
@group(0) @binding(0) var<uniform> P: Params;
@group(0) @binding(1) var frame: texture_2d<f32>;
@group(0) @binding(2) var lut: texture_3d<f32>;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
// One triangle covering the target.
let x = f32((i << 1u) & 2u);
let y = f32(i & 2u);
return vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
}
// The curves take the gamma to decode with, or 1 / gamma to encode with.
// SMPTE ST 2084 and BT.2100 HLG (effectcraft_color::space), linear 1.0 = reference white.
const PQ_M1: f32 = 0.1593017578125;
const PQ_M2: f32 = 78.84375;
const PQ_C1: f32 = 0.8359375;
const PQ_C2: f32 = 18.8515625;
const PQ_C3: f32 = 18.6875;
const PQ_WHITE: f32 = 0.0203;
const HLG_A: f32 = 0.17883277;
const HLG_B: f32 = 0.28466892;
const HLG_C: f32 = 0.55991073;
const HLG_REFERENCE: f32 = 0.26496256;
fn power(a: f32, g: f32) -> f32 {
if (a <= 0.0) {
return 0.0;
}
return pow(a, g);
}
fn copysign(m: f32, v: f32) -> f32 {
if (v < 0.0) {
return -m;
}
return m;
}
fn decode(curve: u32, g: f32, v: f32) -> f32 {
let a = abs(v);
var l = a;
switch curve {
case 1u: {
if (a <= 0.04045) {
l = a / 12.92;
} else {
l = power((a + 0.055) / 1.055, 2.4);
}
}
case 2u: {
l = power(a, g);
}
case 3u: {
let p = power(min(a, 1.0), P.vals2.z);
l = power(max(p - PQ_C1, 0.0) / (PQ_C2 - PQ_C3 * p), P.vals2.w) / PQ_WHITE;
}
case 4u: {
var e: f32;
if (a <= 0.5) {
e = a * a / 3.0;
} else {
e = (exp((a - HLG_C) / HLG_A) + HLG_B) / 12.0;
}
l = e / HLG_REFERENCE;
}
default: {}
}
return copysign(l, v);
}
fn encode(curve: u32, g: f32, v: f32) -> f32 {
let a = abs(v);
var e = a;
switch curve {
case 1u: {
if (a <= 0.0031308) {
e = a * 12.92;
} else {
e = 1.055 * power(a, P.vals2.y) - 0.055;
}
}
case 2u: {
e = power(a, g);
}
case 3u: {
let y = power(a * PQ_WHITE, PQ_M1);
e = power((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2);
}
case 4u: {
let s = a * HLG_REFERENCE;
if (s <= 1.0 / 12.0) {
e = sqrt(3.0 * s);
} else {
e = HLG_A * log(12.0 * s - HLG_B) + HLG_C;
}
}
default: {}
}
return copysign(e, v);
}
fn decode3(curve: u32, g: f32, c: vec3<f32>) -> vec3<f32> {
return vec3<f32>(decode(curve, g, c.x), decode(curve, g, c.y), decode(curve, g, c.z));
}
fn encode3(curve: u32, g: f32, c: vec3<f32>) -> vec3<f32> {
return vec3<f32>(encode(curve, g, c.x), encode(curve, g, c.y), encode(curve, g, c.z));
}
fn mat_mul(m: array<vec4<f32>, 3>, l: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
m[0].x * l.x + m[0].y * l.y + m[0].z * l.z,
m[1].x * l.x + m[1].y * l.y + m[1].z * l.z,
m[2].x * l.x + m[2].y * l.y + m[2].z * l.z,
);
}
// effectcraft_render::color::compand
fn compand(v: f32) -> f32 {
let k = 0.8;
if (v <= k) {
return v;
}
return k + (1.0 - k) * (1.0 - exp(-(v - k) / (1.0 - k)));
}
// effectcraft_render::color::tone_map (extended Reinhard of Rec. 709 luminance, white 4.0)
fn tone_map(c: vec3<f32>) -> vec3<f32> {
let w = 4.0;
let l = 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
if (l <= 0.0) {
return c;
}
let lm = l * (1.0 + l / (w * w)) / (1.0 + l);
let k = lm / l;
return c * k;
}
fn lut_at(n: u32, r: u32, g: u32, b: u32) -> vec3<f32> {
return textureLoad(lut, vec3<u32>(b, g, r), 0).xyz;
}
// effectcraft_engine::viewer::lut_eval: trilinear in the same order.
fn lut_eval(c: vec3<f32>) -> vec3<f32> {
let n = P.ids1.w;
let x = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0)) * f32(n - 1u);
let k = min(vec3<u32>(floor(x)), vec3<u32>(n - 2u));
let f = x - vec3<f32>(k);
var out = vec3<f32>(0.0);
for (var corner = 0u; corner < 8u; corner++) {
let d = vec3<u32>((corner >> 2u) & 1u, (corner >> 1u) & 1u, corner & 1u);
var wr = 1.0 - f.x;
if (d.x == 1u) {
wr = f.x;
}
var wg = 1.0 - f.y;
if (d.y == 1u) {
wg = f.y;
}
var wb = 1.0 - f.z;
if (d.z == 1u) {
wb = f.z;
}
let w = wr * wg * wb;
out = out + w * lut_at(n, k.x + d.x, k.y + d.y, k.z + d.z);
}
return out;
}
fn display_colour(c: vec3<f32>) -> vec3<f32> {
var o = c;
if (P.ids1.x == 2u) {
o = lut_eval(c);
} else if (P.ids1.x == 1u) {
o = decode3(P.ids2.x, P.vals1.x, o);
if (P.vals0.z == 1.0) {
o = mat_mul(P.m1, o);
}
o = encode3(P.ids2.y, P.vals1.y, o);
if (P.ids1.y == 1u) {
o = floor(clamp(o, vec3<f32>(0.0), vec3<f32>(1.0)) * 255.0 + 0.5) / 255.0;
}
if (P.ids1.z == 1u) {
o = decode3(P.ids2.z, P.vals1.z, o);
if (P.vals0.w == 1.0) {
o = mat_mul(P.m2, o);
}
o = encode3(P.ids2.w, P.vals1.w, o);
}
}
return clamp(o, vec3<f32>(0.0), vec3<f32>(1.0));
}
fn q(v: f32) -> f32 {
return floor(clamp(v, 0.0, 1.0) * 255.0 + 0.5) / 255.0;
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let s = textureLoad(frame, vec2<i32>(floor(pos.xy)), 0);
// Frames are never NaN here: `frames::F16Frame` and 8-bit frames hold finite values only
// (the NaN test is the reference's, kept for safety; WGSL may drop it). Alpha is clamped to
// 0..1 first, as `display_pixel` and `DisplayPass::pixel` do (the compositor's alpha never
// exceeds 1).
var a = s.w;
if (!(a == a)) {
a = 0.0;
}
a = clamp(a, 0.0, 1.0);
var c = vec3<f32>(0.0);
if (a > 0.0) {
c = s.xyz / a;
}
let channel = P.ids0.x;
let curve = P.ids0.z;
let g = P.vals0.y;
let gain = P.vals0.x;
let light = gain != 1.0 || P.ids0.w != 0u;
// Premultiplied channels without exposure or HDR step: straight from the frame.
var pm = clamp(s.xyz, vec3<f32>(0.0), vec3<f32>(a));
if (light) {
var l = decode3(curve, g, c) * gain;
if (P.ids0.w == 1u) {
l = vec3<f32>(compand(l.x), compand(l.y), compand(l.z));
} else if (P.ids0.w == 2u) {
l = tone_map(l);
}
c = encode3(curve, P.vals2.x, l);
}
c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
if (light) {
pm = c * a;
}
if (channel == 0u || channel == 5u) {
if (P.ids1.x != 0u) {
c = display_colour(c);
pm = c * a;
}
if (channel == 5u) {
return vec4<f32>(q(c.x), q(c.y), q(c.z), 1.0);
}
return vec4<f32>(q(pm.x), q(pm.y), q(pm.z), q(a));
}
if (channel == 4u) {
var v = a;
if (gain != 1.0) {
v = encode(curve, P.vals2.x, decode(curve, g, a) * gain);
}
let e = q(v);
return vec4<f32>(e, e, e, 1.0);
}
let v = q(pm[channel - 1u]);
if (P.ids0.y == 1u) {
var o = vec4<f32>(0.0, 0.0, 0.0, 1.0);
o[channel - 1u] = v;
return o;
}
return vec4<f32>(v, v, v, 1.0);
}
// The next mip level of the displayed texture (`frame` = the level above): each texel the mean
// of the premultiplied texels it covers, 2 × 2, and at the right / bottom edge of a level above
// of odd size also its last column / row (3 wide or high), so no texel of the frame is dropped
// (the level sizes are the GPU's, rounded down). Rounded as the pass rounds.
@fragment
fn mip(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let above = vec2<i32>(textureDimensions(frame));
let size = max(above / 2, vec2<i32>(1));
let p = vec2<i32>(floor(pos.xy));
let lo = min(p * 2, above - vec2<i32>(1));
var hi = min(p * 2 + vec2<i32>(2), above);
if (p.x == size.x - 1) {
hi.x = above.x;
}
if (p.y == size.y - 1) {
hi.y = above.y;
}
var s = vec4<f32>(0.0);
for (var y = lo.y; y < hi.y; y++) {
for (var x = lo.x; x < hi.x; x++) {
s = s + textureLoad(frame, vec2<i32>(x, y), 0);
}
}
let n = f32((hi.x - lo.x) * (hi.y - lo.y));
return floor(s / n * 255.0 + 0.5) / 255.0;
}
+582
View File
@@ -0,0 +1,582 @@
//! The display pass on a real device (any wgpu adapter; CI runs lavapipe), held to its CPU
//! reference `DisplayPass::pixel`. Skips without an adapter.
use std::sync::{Arc, Mutex};
use effectcraft_engine::project::HdrMode;
use effectcraft_engine::render::Image;
use effectcraft_engine::viewer::{Channel, DisplayColor, DisplayPass, FrameEncoding, PassCurve, SimProfile, Simulation};
use eframe::egui_wgpu;
use eframe::wgpu;
use super::{DisplayGpu, Source};
use crate::frames::F16Frame;
/// One device at a time (tests run in parallel).
static DEVICE: Mutex<()> = Mutex::new(());
struct Dev {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
/// A software rasterizer (lavapipe / llvmpipe, as CI has): its pow / exp / division round
/// differently again, so exact comparisons allow 1 LSB on a few bytes there.
cpu: bool,
}
fn device() -> Option<Dev> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("display pass tests"), ..Default::default() })).ok()?;
let info = adapter.get_info();
eprintln!("display pass tests on {:?} ({:?})", info.name, info.device_type);
Some(Dev { cpu: info.device_type == wgpu::DeviceType::Cpu, instance, adapter, device, queue })
}
/// `compare`'s result is exact, or (software rasterizers) at most 1 LSB on at most 1 % of bytes.
fn exact_enough(d: &Dev, (n, max): (usize, i32), bytes: usize) -> bool {
(n, max) == (0, 0) || d.cpu && max <= 1 && n * 100 <= bytes
}
/// egui-wgpu's render state on the test device, as eframe hands it to the app.
fn render_state(d: &Dev) -> egui_wgpu::RenderState {
let renderer = egui_wgpu::Renderer::new(&d.device, wgpu::TextureFormat::Rgba8Unorm, egui_wgpu::RendererOptions::default());
egui_wgpu::RenderState {
adapter: d.adapter.clone(),
available_adapters: vec![d.adapter.clone()],
instance: d.instance.clone(),
device: d.device.clone(),
queue: d.queue.clone(),
target_format: wgpu::TextureFormat::Rgba8Unorm,
renderer: Arc::new(egui::mutex::RwLock::new(renderer)),
}
}
/// A 17³ LUT that mixes channels and bends the curve (as an ICC profile's device does).
fn test_lut(k: f64) -> Arc<effectcraft_engine::color::icc::Lut3d> {
Arc::new(effectcraft_engine::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() * k]
}))
}
fn read(d: &Dev, tex: &wgpu::Texture, level: u32) -> (Vec<[u8; 4]>, [u32; 2]) {
let (w, h) = ((tex.width() >> level).max(1), (tex.height() >> level).max(1));
let stride = (w * 4).div_ceil(256) * 256;
let buf = d.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: (stride * h) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut e = d.device.create_command_encoder(&Default::default());
e.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture: tex, mip_level: level, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(stride), rows_per_image: Some(h) } },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
d.queue.submit([e.finish()]);
buf.slice(..).map_async(wgpu::MapMode::Read, |r| r.expect("map"));
d.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let data = buf.slice(..).get_mapped_range().expect("mapped").to_vec();
let mut out = Vec::with_capacity((w * h) as usize);
for y in 0..h {
let row = &data[(y * stride) as usize..][..(w * 4) as usize];
out.extend(row.as_chunks::<4>().0.iter().copied());
}
(out, [w, h])
}
/// The pass on the device: level 0 of the displayed texture, and the texture.
fn bake(d: &Dev, gpu: &DisplayGpu, src: &Source, pass: &DisplayPass) -> (Vec<[u8; 4]>, wgpu::Texture) {
let k = crate::frames::fit_factor(src.size(), gpu.max_side(), 1);
let input = gpu.upload(&d.device, &d.queue, src, k, None);
let out = DisplayGpu::output(&d.device, input.width(), input.height());
let lut = match &pass.color {
Some(effectcraft_engine::viewer::PassColor::Lut(l)) => Some(gpu.lut(&d.device, &d.queue, l)),
_ => None,
};
gpu.run(&d.device, &d.queue, &input, &out, &super::uniform(pass), lut.as_ref());
(read(d, &out, 0).0, out)
}
fn frame(px: &[[f32; 4]], w: u32, encoding: FrameEncoding) -> Arc<F16Frame> {
let mut img = Image::new(w, px.len() as u32 / w);
img.data.copy_from_slice(px);
Arc::new(F16Frame::from_image(&img, encoding))
}
/// GPU output against the reference on the frame's own (half-float) values: the number of
/// bytes that differ and the largest difference.
fn compare(got: &[[u8; 4]], f: &F16Frame, pass: &DisplayPass) -> (usize, i32) {
let (mut n, mut max) = (0, 0);
for (i, g) in got.iter().enumerate() {
let want = pass.pixel(f.pixel(i).expect("pixel"));
for c in 0..4 {
let d = (g[c] as i32 - want[c] as i32).abs();
if d > 0 {
n += 1;
max = max.max(d);
}
}
}
(n, max)
}
const SRGB: FrameEncoding = FrameEncoding { curve: PassCurve::Srgb, hdr: HdrMode::Clip };
fn grey(l: f32) -> [f32; 4] {
let e = PassCurve::Srgb.encode(l);
[e, e, e, 1.0]
}
/// The issue's values (4.0 at -2 EV is white, 0.5 at +1 EV is white) and the HDR golden table,
/// bit for bit on the GPU.
#[test]
fn hdr_values_on_the_gpu() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
let ls = [0.25f32, 0.5, 1.0, 2.0, 4.0, 16.0];
let f = frame(&ls.map(grey), 6, SRGB);
let want: [[u8; 6]; 5] = [
[137, 188, 255, 255, 255, 255],
[99, 137, 188, 255, 255, 255],
[71, 99, 137, 188, 255, 255],
[34, 49, 71, 99, 137, 255],
[188, 255, 255, 255, 255, 255],
];
for (stops, w) in [0.0f32, -1.0, -2.0, -4.0, 1.0].into_iter().zip(want) {
let pass = DisplayPass::new(Channel::Rgb, false, stops, SRGB, None);
let (got, _) = bake(&d, &gpu, &Source::F16(f.clone()), &pass);
let r: Vec<u8> = got.iter().map(|p| p[0]).collect();
assert!(r.iter().zip(w).all(|(g, w)| *g == w || d.cpu && g.abs_diff(w) <= 1), "{stops} EV: {r:?} vs {w:?}");
let c = compare(&got, &f, &pass);
assert!(c == (0, 0) || d.cpu && c.1 <= 1, "{stops} EV: {c:?}");
}
}
/// Exposure 0 on half floats: bit for bit the reference, and the bytes the 8-bit viewer showed
/// for values in 0..1 (at least 97.9 % equal, never more than 1 apart: the half-float storage).
#[test]
fn exposure_zero_parity_on_the_gpu() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
let pass = DisplayPass::new(Channel::Rgb, false, 0.0, SRGB, None);
for alpha in [1.0f32, 0.5] {
let img = crate::frames::tests::parity_frame(alpha);
let f = Arc::new(F16Frame::from_image(&img, SRGB));
let (got, _) = bake(&d, &gpu, &Source::F16(f.clone()), &pass);
let c = compare(&got, &f, &pass);
assert!(exact_enough(&d, c, got.len() * 4), "alpha {alpha}: {c:?}");
let old = crate::frames::to_color_image(&img);
let (mut same, mut max) = (0, 0);
for (g, o) in got.iter().zip(&old.pixels) {
let o = o.to_array();
for c in 0..3 {
same += (g[c] == o[c]) as usize;
max = max.max((g[c] as i32 - o[c] as i32).abs());
}
assert_eq!(g[3], o[3]);
}
let share = same as f64 / (got.len() * 3) as f64;
eprintln!("GPU, alpha {alpha}: {:.2} % of colour bytes as before, max {max}", share * 100.0);
assert!(share >= 0.979 && max <= 1, "alpha {alpha}: {share}, max {max}");
}
// The issue's two values, exactly.
let f = frame(&[grey(4.0), grey(0.5)], 2, SRGB);
let (m2, _) = bake(&d, &gpu, &Source::F16(f.clone()), &DisplayPass::new(Channel::Rgb, false, -2.0, SRGB, None));
let (p1, _) = bake(&d, &gpu, &Source::F16(f), &DisplayPass::new(Channel::Rgb, false, 1.0, SRGB, None));
let white = |p: [u8; 4]| p.iter().all(|c| *c == 255 || d.cpu && *c >= 254);
assert!(white(m2[0]) && white(p1[1]), "{:?} {:?}", m2[0], p1[1]);
}
/// Every pass setting the viewer has, against the reference on the same half-float input:
/// exposure, HDR step (after exposure), Show Channel, curves and display colour.
#[test]
fn the_shader_is_the_reference() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
// 64 × 64: colours 0..2 in every channel, alpha 1, 0.5 and a few others, some over range.
let px: Vec<[f32; 4]> = (0..64 * 64)
.map(|i| {
let (x, y) = (i % 64, i / 64);
let a = [1.0, 0.5, 0.25, 0.8, 1.0, 1.0, 0.0, 1.0][y % 8];
// Row 5: negative values too (32 bpc frames can hold them).
let lo = if y % 8 == 5 { 0.75 } else { 0.0 };
let v = |k: usize| ((x * 7 + y * k) % 64) as f32 / 32.0 - lo;
[v(3) * a, v(5) * a, v(11) * a, a]
})
.collect();
let dcs = [
None,
DisplayColor::new(
Some(effectcraft_engine::project::ColorSpace::Srgb),
false,
effectcraft_engine::project::ColorSpace::Srgb,
true,
effectcraft_engine::project::ColorSpace::DisplayP3,
Simulation::default(),
&Default::default(),
),
DisplayColor::new(
Some(effectcraft_engine::project::ColorSpace::Srgb),
false,
effectcraft_engine::project::ColorSpace::Srgb,
true,
effectcraft_engine::project::ColorSpace::Srgb,
Simulation { profile: SimProfile::Rec709, preserve_rgb: false },
&Default::default(),
),
DisplayColor::new(
Some(effectcraft_engine::project::ColorSpace::Srgb),
false,
effectcraft_engine::project::ColorSpace::Srgb,
true,
effectcraft_engine::project::ColorSpace::DisplayP3,
Simulation { profile: SimProfile::Mac18, preserve_rgb: true },
&Default::default(),
),
// A LUT-based My Custom RGB profile.
Some(DisplayColor::from_lut(test_lut(1.0))),
];
let (mut report, mut off, mut total) = (vec![], 0usize, 0usize);
for curve in [PassCurve::Srgb, PassCurve::Gamma(2.4), PassCurve::Linear, PassCurve::Pq, PassCurve::Hlg] {
for hdr in [HdrMode::Clip, HdrMode::Compand, HdrMode::ToneMap] {
let enc = FrameEncoding { curve, hdr };
let f = frame(&px, 64, enc);
for stops in [0.0f32, 1.0, -2.0, 2.5] {
for (ci, dc) in dcs.iter().enumerate() {
for (channel, colorized) in
[(Channel::Rgb, false), (Channel::RgbStraight, false), (Channel::Red, false), (Channel::Green, true), (Channel::Alpha, false)]
{
if ci > 0 && !matches!(channel, Channel::Rgb) {
continue;
}
let pass = DisplayPass::new(channel, colorized, stops, enc, dc.as_ref());
let (got, _) = bake(&d, &gpu, &Source::F16(f.clone()), &pass);
let (n, max) = compare(&got, &f, &pass);
// The device's pow / exp / ln / division are not the CPU's to the last
// bit: a value on a rounding tie can land either side of it.
// PQ's exponent of 78.8 magnifies those last bits most.
// Software rasterizers (CI's lavapipe) differ more.
let share = if matches!(curve, PassCurve::Pq | PassCurve::Hlg) { 5 } else { 1 } * if d.cpu { 5 } else { 1 };
assert!(max <= 1 && n * 100 <= got.len() * 4 * share, "{curve:?} {hdr:?} {stops} EV {channel:?} dc {ci}: {n} bytes off, max {max}");
off += n;
total += got.len() * 4;
if n > 0 {
report.push(format!("{curve:?} {hdr:?} {stops} {channel:?} {ci}: {n}"));
}
}
}
}
}
}
eprintln!("{off} of {total} bytes 1 LSB off the reference: {report:?}");
}
/// 8-bit frames go through the same pass (exposure before the display colour there too), equal
/// to the CPU pass on their bytes.
#[test]
fn rgba8_frames_match_the_cpu_pass() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
let px: Vec<egui::Color32> = (0..256 * 16)
.map(|i| {
let (x, y) = (i % 256, i / 256);
let a = [255u8, 128, 64, 200][y % 4];
let c = |k: usize| ((x * k + y) % 256) as u32 * a as u32 / 255;
egui::Color32::from_rgba_premultiplied(c(1) as u8, c(3) as u8, c(7) as u8, a)
})
.collect();
let img = Arc::new(egui::ColorImage::new([256, 16], px));
let dc = DisplayColor::new(
Some(effectcraft_engine::project::ColorSpace::Srgb),
false,
effectcraft_engine::project::ColorSpace::Srgb,
true,
effectcraft_engine::project::ColorSpace::DisplayP3,
Simulation::default(),
&Default::default(),
);
for stops in [0.0f32, 1.0, -1.5] {
for dc in [None, dc.as_ref()] {
let pass = DisplayPass::new(Channel::Rgb, false, stops, SRGB, dc);
let (got, _) = bake(&d, &gpu, &Source::Rgba8(img.clone()), &pass);
let mut want: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
pass.apply_rgba8(&mut want);
let off = got.iter().zip(&want).flat_map(|(g, w)| (0..4).map(move |c| (g[c] as i32 - w[c] as i32).abs())).filter(|d| *d > 0).collect::<Vec<_>>();
eprintln!("8-bit, {stops} EV, dc {}: {} bytes 1 LSB off", dc.is_some(), off.len());
assert!(off.iter().all(|d| *d <= 1) && off.len() * 100 <= got.len() * 4, "{stops} EV, dc {}: {off:?}", dc.is_some());
if stops == 0.0 && dc.is_none() {
assert_eq!(got, want, "nothing to change: the frame's bytes");
}
}
}
}
/// The mips: each texel the rounded mean of the 2 × 2 texels above it, and at the edge of an
/// odd-sized level above also of its last column / row, so no frame pixel is dropped.
#[test]
fn mips_average_the_level_above() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
let px: Vec<[f32; 4]> = (0..37 * 21).map(|i| [(i % 37) as f32 / 36.0, (i / 37) as f32 / 20.0, 0.3, 1.0]).collect();
let f = frame(&px, 37, SRGB);
let (_, tex) = bake(&d, &gpu, &Source::F16(f), &DisplayPass::new(Channel::Rgb, false, 0.5, SRGB, None));
assert_eq!(tex.mip_level_count(), 6);
for l in 1..tex.mip_level_count() {
let (above, [aw, ah]) = read(&d, &tex, l - 1);
let (got, [w, h]) = read(&d, &tex, l);
for y in 0..h {
for x in 0..w {
let (x0, y0) = ((x * 2).min(aw - 1), (y * 2).min(ah - 1));
let x1 = if x == w - 1 { aw } else { (x * 2 + 2).min(aw) };
let y1 = if y == h - 1 { ah } else { (y * 2 + 2).min(ah) };
let n = ((x1 - x0) * (y1 - y0)) as f32;
let want: [u8; 4] = std::array::from_fn(|c| {
let s: f32 =
(y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| above[(yy * aw + xx) as usize][c] as f32 / 255.0).sum();
(s / n * 255.0 + 0.5).floor() as u8
});
let g = got[(y * w + x) as usize];
assert!((0..4).all(|c| (g[c] as i32 - want[c] as i32).abs() <= 1), "level {l} ({x}, {y}): {g:?} vs {want:?}");
}
}
}
}
/// One egui frame through egui_wgpu's renderer into an offscreen target (eframe minus the
/// swapchain): RGBA8 of the framebuffer.
fn render_egui(
d: &Dev,
renderer: &mut egui_wgpu::Renderer,
ctx: &egui::Context,
size_pts: [f32; 2],
ppp: f32,
ui_fn: &mut dyn FnMut(&mut egui::Ui),
) -> (Vec<[u8; 4]>, [u32; 2]) {
ctx.set_pixels_per_point(ppp);
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size_pts[0], size_pts[1]))), ..Default::default() };
let mut first = ctx.run_ui(raw.clone(), |ui| ui_fn(ui));
let mut out = ctx.run_ui(raw, |ui| ui_fn(ui));
for o in [&mut first, &mut out] {
for (id, delta) in o.textures_delta.set.iter().flat_map(|(id, v)| v.iter().map(move |d| (id, d))) {
renderer.update_texture(&d.device, &d.queue, *id, delta);
}
for id in &o.textures_delta.free {
renderer.free_texture(id);
}
o.textures_delta.clear();
}
let jobs = ctx.tessellate(out.shapes, out.pixels_per_point);
let size = [(size_pts[0] * ppp).round() as u32, (size_pts[1] * ppp).round() as u32];
let sd = egui_wgpu::ScreenDescriptor { size_in_pixels: size, pixels_per_point: out.pixels_per_point };
let target = d.device.create_texture(&wgpu::TextureDescriptor {
label: Some("framebuffer"),
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut enc = d.device.create_command_encoder(&Default::default());
let user = renderer.update_buffers(&d.device, &d.queue, &mut enc, &jobs, &sd);
{
let pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("egui"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
..Default::default()
});
let mut pass = pass.forget_lifetime();
renderer.render(&mut pass, &jobs, &sd);
}
d.queue.submit(user.into_iter().chain([enc.finish()]));
(read(d, &target, 0).0, size)
}
/// The displayed texture drawn by egui as the viewer draws it (native texture, the Faster
/// sampler: nearest): at 2 pixels per point, zoomed 8× and 13.37× with the frame past every
/// edge of a clip rect, each screen pixel shows the texel under its centre; and over egui's
/// checkerboard, half-transparent pixels blend premultiplied.
#[test]
fn the_displayed_texture_lands_where_the_viewer_draws_it() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
let mut renderer =
egui_wgpu::Renderer::new(&d.device, wgpu::TextureFormat::Rgba8Unorm, egui_wgpu::RendererOptions { dithering: false, ..Default::default() });
let ctx = egui::Context::default();
let (tw, th) = (64u32, 32u32);
let px: Vec<[f32; 4]> = (0..tw * th).map(|i| [(i % tw) as f32 / 63.0, (i / tw) as f32 / 31.0, 1.0, 1.0]).collect();
let f = frame(&px, tw, SRGB);
let pass = DisplayPass::new(Channel::Rgb, false, -1.0, SRGB, None);
let (texels, tex) = bake(&d, &gpu, &Source::F16(f), &pass);
let id =
renderer.register_native_texture_with_sampler_options(&d.device, &tex.create_view(&Default::default()), crate::panels::viewer::zoom_sampler(false));
let ppp = 2.0f32;
let clip = egui::Rect::from_min_max(egui::pos2(50.0, 40.0), egui::pos2(350.0, 260.0));
for (zoom, origin) in [(2.0f32, egui::pos2(80.0, 60.0)), (8.0, egui::pos2(-100.0, -60.0)), (13.37, egui::pos2(-151.3, -77.7))] {
let rect = egui::Rect::from_min_size(origin, egui::vec2(tw as f32 * zoom, th as f32 * zoom));
let mut ui_fn = |ui: &mut egui::Ui| {
let p = ui.painter().with_clip_rect(clip);
p.image(id, rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
};
let (out, sz) = render_egui(&d, &mut renderer, &ctx, [400.0, 300.0], ppp, &mut ui_fn);
let (mut bad, mut drawn) = (0, 0);
for y in 0..sz[1] {
for x in 0..sz[0] {
let (cx, cy) = ((x as f32 + 0.5) / ppp, (y as f32 + 0.5) / ppp);
let (u, v) = ((cx - rect.min.x) / zoom, (cy - rect.min.y) / zoom);
// Centres within a hair of a texel or clip edge can go either way.
let near = |t: f32| (t - t.round()).abs() < 0.01;
if near(u)
|| near(v)
|| near(cx * ppp - clip.min.x * ppp)
|| !clip.contains(egui::pos2(cx, cy))
|| u < 0.0
|| v < 0.0
|| u >= tw as f32
|| v >= th as f32
{
continue;
}
drawn += 1;
let want = texels[(v as u32 * tw + u as u32) as usize];
let got = out[(y * sz[0] + x) as usize];
if (0..4).any(|c| (got[c] as i32 - want[c] as i32).abs() > 1) {
bad += 1;
}
}
}
assert!(drawn > 1000, "{drawn}");
assert_eq!(bad, 0, "zoom {zoom}, origin {origin:?}: {bad} of {drawn} pixels wrong");
}
renderer.free_texture(&id);
// Alpha 0.5 over a checkerboard.
let src = [0.5f32, 0.25, 0.0, 0.5];
let f = frame(&[src; 256], 16, SRGB);
let (texels, tex) = bake(&d, &gpu, &Source::F16(f), &DisplayPass::new(Channel::Rgb, false, 0.0, SRGB, None));
let id =
renderer.register_native_texture_with_sampler_options(&d.device, &tex.create_view(&Default::default()), crate::panels::viewer::zoom_sampler(false));
let (c1, c2) = (egui::Color32::from_gray(0x66), egui::Color32::from_gray(0x99));
let mut ui_fn = |ui: &mut egui::Ui| {
let p = ui.painter();
for j in 0..8 {
for i in 0..8 {
let r = egui::Rect::from_min_size(egui::pos2(i as f32 * 8.0, j as f32 * 8.0), egui::vec2(8.0, 8.0));
p.rect_filled(r, 0.0, if (i + j) % 2 == 0 { c1 } else { c2 });
}
}
p.image(
id,
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0)),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
};
let (out, sz) = render_egui(&d, &mut renderer, &ctx, [64.0, 64.0], 2.0, &mut ui_fn);
let s = texels[0];
let mut bad = 0;
for y in 0..sz[1] {
for x in 0..sz[0] {
let (i, j) = (x / 16, y / 16);
let dst = if (i + j) % 2 == 0 { 0x66 } else { 0x99 } as f32;
let want: [u8; 3] = std::array::from_fn(|c| (s[c] as f32 + dst * (1.0 - s[3] as f32 / 255.0)).round() as u8);
let got = out[(y * sz[0] + x) as usize];
if (0..3).any(|c| (got[c] as i32 - want[c] as i32).abs() > 1) {
bad += 1;
}
}
}
assert_eq!(bad, 0, "alpha over the checkerboard: {bad} pixels off");
}
/// Timings at 4K (`cargo test -- --ignored display_pass_timings --nocapture`): the f32 → half
/// conversion of a frame, today's f32 → RGBA8 conversion, and the display pass.
#[test]
#[ignore = "timing, not a check"]
fn display_pass_timings() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let img = Image::filled(3840, 2160, [0.7, 1.9, 0.2, 1.0]);
let time = |f: &mut dyn FnMut()| {
let mut v: Vec<f64> = (0..9)
.map(|_| {
let t = std::time::Instant::now();
f();
t.elapsed().as_secs_f64() * 1000.0
})
.collect();
v.sort_by(f64::total_cmp);
v[4]
};
let f16 = time(&mut || drop(F16Frame::from_image(&img, SRGB)));
let rgba8 = time(&mut || drop(crate::frames::to_color_image(&img)));
eprintln!("4K: f32 → f16 {f16:.2} ms, f32 → RGBA8 {rgba8:.2} ms (medians of 9)");
let Some(d) = device() else { return };
let gpu = DisplayGpu::new(&d.device);
let f = Arc::new(F16Frame::from_image(&img, SRGB));
let input = gpu.upload(&d.device, &d.queue, &Source::F16(f), 1, None);
let out = DisplayGpu::output(&d.device, 3840, 2160);
let params = super::uniform(&DisplayPass::new(Channel::Rgb, false, -1.0, FrameEncoding { curve: PassCurve::Srgb, hdr: HdrMode::ToneMap }, None));
let pass = time(&mut || {
gpu.run(&d.device, &d.queue, &input, &out, &params, None);
d.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
});
let f = Arc::new(F16Frame::from_image(&img, SRGB));
let mut kept = Some(gpu.upload(&d.device, &d.queue, &Source::F16(f.clone()), 1, None));
let upload = time(&mut || {
kept = Some(gpu.upload(&d.device, &d.queue, &Source::F16(f.clone()), 1, kept.take()));
d.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
});
let cpu = time(&mut || drop(f.to_color_image(&DisplayPass::new(Channel::Rgb, false, -1.0, SRGB, None))));
eprintln!("4K: half-float upload into the kept texture {upload:.2} ms; the pass on the CPU (no device) {cpu:.2} ms");
eprintln!("4K: display pass with mips, submit to idle {pass:.2} ms");
}
/// `Bake::show` on egui-wgpu's render state: it runs the pass again when the frame, the pass's
/// settings or the LUT change (a new LUT of the same size included: another ICC profile,
/// Preserve RGB, another display), and only then; the texture id stays.
#[test]
fn bakes_run_again_when_frame_pass_or_lut_change() {
let _g = DEVICE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(d) = device() else { return eprintln!("no wgpu adapter: skipping") };
let gpu = DisplayGpu::new(&d.device);
let rs = render_state(&d);
let px: Vec<[f32; 4]> = (0..16).map(|i| [i as f32 / 15.0, 0.4, 0.7, 1.0]).collect();
let f = frame(&px, 4, SRGB);
let lut = |k: f64| DisplayColor::from_lut(test_lut(k));
let (l1, l2) = (lut(1.0), lut(0.5));
let pass = |stops: f32, dc: &DisplayColor| DisplayPass::new(Channel::Rgb, false, stops, SRGB, Some(dc));
let mut b = super::Bake::default();
let id = b.show(&rs, &gpu, &Source::F16(f.clone()), &pass(0.0, &l1), true).unwrap();
let first = read(&d, b.texture().unwrap(), 0).0;
assert_eq!(b.runs(), 1);
b.show(&rs, &gpu, &Source::F16(f.clone()), &pass(0.0, &l1), true);
assert_eq!(b.runs(), 1, "nothing changed");
assert_eq!(b.show(&rs, &gpu, &Source::F16(f.clone()), &pass(0.0, &l2), true), Some(id));
assert_eq!(b.runs(), 2, "a new LUT of the same size");
let second = read(&d, b.texture().unwrap(), 0).0;
assert_ne!(first, second);
let want: Vec<[u8; 4]> = (0..16).map(|i| pass(0.0, &l2).pixel(f.pixel(i).unwrap())).collect();
assert!(second.iter().zip(&want).all(|(g, w)| (0..4).all(|c| g[c].abs_diff(w[c]) <= 1)), "{second:?} vs {want:?}");
b.show(&rs, &gpu, &Source::F16(f.clone()), &pass(-1.0, &l2), true);
assert_eq!(b.runs(), 3, "another exposure");
b.show(&rs, &gpu, &Source::F16(frame(&px, 4, SRGB)), &pass(-1.0, &l2), true);
assert_eq!(b.runs(), 4, "another frame");
b.free(Some(&rs));
assert!(b.id().is_none());
}
+82 -20
View File
@@ -23,6 +23,10 @@
//! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer
//! registers with egui-wgpu and draws directly; pixels are read back only when something needs
//! them (Info panel, eyedroppers, histograms).
//!
//! 32 bpc projects keep half-float frames on the desktop ([`F16Frame`], rendered without the HDR
//! step): twice the bytes of an 8-bit frame, so the RAM preview holds half as many. The disk
//! cache keeps them as half-float entries.
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -47,10 +51,14 @@ const MIN_GPU_BUDGET: usize = 64 << 20;
const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5);
const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
/// A rendered viewer frame: CPU pixels or a GPU texture.
mod f16;
pub use f16::{F16_PIXEL_BYTES, F16Frame};
/// A rendered viewer frame: 8-bit CPU pixels, half-float CPU pixels (32 bpc) or a GPU texture.
#[derive(Clone)]
pub enum FrameImage {
Cpu(Arc<egui::ColorImage>),
CpuF16(Arc<F16Frame>),
Gpu(Arc<DisplayFrame>),
}
@@ -58,11 +66,14 @@ impl FrameImage {
pub fn size(&self) -> [usize; 2] {
match self {
FrameImage::Cpu(c) => c.size,
FrameImage::CpuF16(f) => f.size(),
FrameImage::Gpu(f) => [f.width as usize, f.height as usize],
}
}
fn bytes(&self) -> usize {
self.size()[0] * self.size()[1] * 4
/// Bytes the frame holds in the RAM preview (GPU frames: without their mips).
pub fn bytes(&self) -> usize {
let [w, h] = self.size();
w * h * if matches!(self, FrameImage::CpuF16(_)) { F16_PIXEL_BYTES } else { 4 }
}
fn is_gpu(&self) -> bool {
matches!(self, FrameImage::Gpu(_))
@@ -615,6 +626,29 @@ impl Default for Frames {
}
}
/// Whether viewer frames of a project with `settings` are half floats ([`F16Frame`], rendered
/// with `RenderOpts::skip_hdr_step`): 32 bpc on the desktop. The browser's frames (its frame
/// worker's RGBA8, the GPU compositor's display textures) stay 8-bit for now.
pub fn f16_frames(settings: &effectcraft_engine::project::ProjectSettings) -> bool {
cfg!(not(target_arch = "wasm32")) && settings.bit_depth.levels().is_none()
}
/// Bytes per pixel of the viewer frames of a project with `settings` (see [`f16_frames`]).
pub fn frame_pixel_bytes(settings: &effectcraft_engine::project::ProjectSettings) -> usize {
if f16_frames(settings) { F16_PIXEL_BYTES } else { 4 }
}
/// A rendered frame as a cached viewer frame: half floats for `skip_hdr_step` renders (see
/// [`f16_frames`]), else 8-bit.
pub fn cpu_frame(img: &effectcraft_engine::render::Image, project: &Project, opts: &RenderOpts) -> FrameImage {
if opts.skip_hdr_step {
let encoding = effectcraft_engine::viewer::FrameEncoding::of(&project.settings, true);
FrameImage::CpuF16(Arc::new(F16Frame::from_image(img, encoding)))
} else {
FrameImage::Cpu(Arc::new(to_color_image(img)))
}
}
/// Premultiplied f32 → egui premultiplied Color32 (alpha kept, so the viewer can show the
/// transparency grid or the comp background colour underneath).
pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorImage {
@@ -974,7 +1008,7 @@ impl Frames {
self.request_content_key(src.project.clone(), base.revision, base.comp);
return true;
};
let (missing, converting): (Vec<(u128, Arc<egui::ColorImage>)>, bool) = {
let (missing, converting): (Vec<(u128, FrameImage)>, bool) = {
let (Ok(c), Ok(mut queued), Ok(mut written)) = (self.cache.lock(), self.persisted.lock(), self.written.lock()) else { return false };
if queued.len() > 100_000 {
queued.clear();
@@ -988,7 +1022,10 @@ impl Frames {
.iter()
.filter(|(k, _)| k.same_series(base))
.filter_map(|(k, img)| {
let FrameImage::Cpu(img) = img else { return None };
// (GPU frames have no CPU pixels to write)
if img.is_gpu() {
return None;
}
let dk = disk_cache::frame_key(content, k.frame, k.scale, k.view ^ oh);
let tries = written.1.get(&dk).copied().unwrap_or(0);
(tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)).then(|| (dk, img.clone()))
@@ -1002,12 +1039,17 @@ impl Frames {
let (persisted, written) = (self.persisted.clone(), self.written.clone());
let work = move || {
for (dk, img) in missing {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
// (accepted: the disk cache tracks it until written, and it counts as a try;
// refused by a full queue: retried, without counting)
if dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba)
&& let Ok(mut w) = written.lock()
{
let accepted = match &img {
FrameImage::Cpu(img) => {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba)
}
FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits()),
FrameImage::Gpu(_) => false,
};
if accepted && let Ok(mut w) = written.lock() {
let n = w.1.entry(dk).or_default();
*n = n.saturating_add(1);
}
@@ -1563,10 +1605,20 @@ impl Worker {
.disk
.as_ref()
.map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref())));
let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k));
// Half-float frames (32 bpc) have entries of their own (their key has the options).
let from_disk = disk.as_ref().and_then(|(dc, k)| {
if job.opts.skip_hdr_step {
let f = dc.get_frame_f16(*k)?;
let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true);
Some(FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding))))
} else {
let f = dc.get_frame(*k)?;
Some(FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))))
}
});
let disk_hit = from_disk.is_some();
let ci = match from_disk {
Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))),
Some(f) => f,
None => match self.render_guarded(&job).filter(|_| !job.cancel.load(Ordering::Relaxed)) {
Some(ci) => ci,
// Called off (frames it read may be missing) or failed: not cached, and not
@@ -1580,10 +1632,20 @@ impl Worker {
// Publish the shared frame and wake the viewer before optional RGBA conversion
// and compression. Persistence still runs on this worker, using the same snapshot.
self.finish(&job, ci.clone(), t0);
if !disk_hit && let (Some((dc, k)), FrameImage::Cpu(img)) = (&disk, &ci) {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba);
if let Some(ctx) = &self.ctx {
if !disk_hit && let Some((dc, k)) = &disk {
let written = match &ci {
FrameImage::Cpu(img) => {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba);
true
}
FrameImage::CpuF16(f) => {
dc.put_frame_f16(*k, f.width, f.height, f.bits());
true
}
FrameImage::Gpu(_) => false,
};
if written && let Some(ctx) = &self.ctx {
ctx.request_repaint();
}
}
@@ -1745,18 +1807,18 @@ impl Worker {
}
}
let t0 = web_time::Instant::now();
let img = to_color_image(&r.comp_frame_cpu(job.comp, job.t));
let img = cpu_frame(&r.comp_frame_cpu(job.comp, job.t), &job.src.project, &job.opts);
if let Some((p, key)) = auto {
p.record(key, false, t0.elapsed().as_secs_f64() * 1000.0);
}
return FrameImage::Cpu(Arc::new(img));
return img;
}
if let Some(g) = gpu {
// The GPU composites and the frame is read back.
return match g.within_memory(|| r.comp_frame(job.comp, job.t)) {
Ok(img) => {
self.gpu_succeeded();
FrameImage::Cpu(Arc::new(to_color_image(&img)))
cpu_frame(&img, &job.src.project, &job.opts)
}
Err(e) => {
self.gpu_attempt_failed(g, &e);
@@ -1764,7 +1826,7 @@ impl Worker {
}
};
}
FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t))))
cpu_frame(&r.comp_frame(job.comp, job.t), &job.src.project, &job.opts)
}
}
@@ -1863,4 +1925,4 @@ pub(crate) fn render_scale(thousandths: u32, factor: Option<u8>) -> f64 {
}
#[cfg(test)]
mod tests;
pub(crate) mod tests;
+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(
+20 -3
View File
@@ -24,6 +24,7 @@ pub mod bench;
pub mod color_picker;
pub mod control;
pub mod credits;
pub mod display_gpu;
pub mod dock;
pub mod dock_ui;
mod expression_diagnostics;
@@ -314,12 +315,28 @@ pub struct EffectcraftApp {
/// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture.
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
/// The texture the viewer draws and its frame key (CPU or GPU frame).
pub(crate) viewer_shown: Option<(egui::TextureId, FrameKey)>,
/// (`None` texture: a half-float frame, drawn by the display pass.)
pub(crate) viewer_shown: Option<(Option<egui::TextureId>, FrameKey)>,
/// A Cryptomatte eyedropper probe is being built (one at a time, re-arming included).
pub(crate) crypto_probe_busy: Arc<std::sync::atomic::AtomicBool>,
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
/// demand ([`EffectcraftApp::viewer_pixels`]).
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames and
/// half-float frames fill it on demand ([`EffectcraftApp::viewer_pixels`]).
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
/// The half-float frame on screen (32 bpc projects; its key is `viewer_shown`'s).
pub(crate) viewer_f16: Option<Arc<frames::F16Frame>>,
/// The window's egui-wgpu device (with or without a GPU compositor on it): the display pass
/// runs there.
pub(crate) window_wgpu: Option<eframe::egui_wgpu::RenderState>,
/// The display pass on that device (made with the first frame that needs it), and whether
/// making it failed (the pass then runs on the CPU).
pub(crate) display_gpu: Option<Arc<display_gpu::DisplayGpu>>,
pub(crate) display_gpu_failed: bool,
/// The Composition viewer's and the snapshot's displayed textures (display pass).
pub(crate) viewer_bake: display_gpu::Bake,
pub(crate) snapshot_bake: display_gpu::Bake,
/// The snapshot in half floats for the display pass (the image it was made from, not kept
/// alive by this).
pub(crate) snapshot_f16: Option<(std::sync::Weak<effectcraft_engine::render::Image>, Arc<frames::F16Frame>)>,
/// wasm32: an asynchronous readback of a GPU viewer frame (key asked for, result).
viewer_readback: (Option<FrameKey>, Arc<std::sync::Mutex<Option<(FrameKey, Arc<egui::ColorImage>)>>>),
/// egui-wgpu's device, once the first frame arrives (desktop and WebGPU).
+31 -13
View File
@@ -18,16 +18,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let p = ui.painter().with_clip_rect(rect);
let x0 = rect.min.x + 12.0;
let mut y = rect.min.y + 14.0 - scroll.offset;
if app.pointer_comp.is_some() {
let comp_size = app.session.active_comp().map_or([1.0, 1.0], |c| [c.width as f32, c.height as f32]);
let region = || super::viewer_tools::shown_region(ui.ctx(), comp_size);
// 32 bpc (half-float frames): the frame's float values, straight colour as rendered (before
// Project Settings ▸ HDR and the viewer's exposure), as After Effects shows them in 32 bpc.
let floats = match (app.pointer_comp, app.viewer_f16()) {
(Some([cx, cy]), Some(f)) => {
let p = super::fx_widgets::frame_pixel_index(f.size(), region(), [cx as f64, cy as f64]).and_then(|i| f.pixel(i));
let un = |p: [f32; 4]| {
let a = p[3].clamp(0.0, 1.0);
if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a] } else { [0.0; 4] }
};
Some((p.map(un), (cx, cy)))
}
_ => None,
};
if app.pointer_comp.is_some() && floats.is_none() {
// GPU frames: read the shown frame back for sampling.
app.viewer_pixels();
}
let (rgba, xy) = match (app.pointer_comp, &app.viewer_image) {
(Some([cx, cy]), Some(img)) => {
let comp = app.session.active_comp().map_or([1.0, 1.0], |c| [c.width as f32, c.height as f32]);
let region = super::viewer_tools::shown_region(ui.ctx(), comp);
(super::fx_widgets::frame_pixel(img, region, [cx as f64, cy as f64]), Some((cx, cy)))
}
_ if floats.is_some() => (None, floats.map(|(_, xy)| xy)),
(Some([cx, cy]), Some(img)) => (super::fx_widgets::frame_pixel(img, region(), [cx as f64, cy as f64]), Some((cx, cy))),
_ => (None, None),
};
let row = |p: &egui::Painter, y: f32, k: &str, v: String, col: Color32| {
@@ -43,20 +55,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
[f(c.r()), f(c.g()), f(c.b()), a]
}
};
let px = rgba.map(unpre).unwrap_or([0, 0, 0, 0]);
row(&p, y, "R :", format!("{}", px[0]), Color32::from_rgb(0xe0, 0x60, 0x60));
row(&p, y + 18.0, "G :", format!("{}", px[1]), Color32::from_rgb(0x60, 0xd0, 0x60));
row(&p, y + 36.0, "B :", format!("{}", px[2]), Color32::from_rgb(0x60, 0x90, 0xf0));
row(&p, y + 54.0, "A :", format!("{}", px[3]), t.text_dim);
let (texts, swatch): ([String; 4], Option<[u8; 4]>) = match floats {
Some((p, _)) => (p.unwrap_or([0.0; 4]).map(|v| format!("{v:.4}")), p.map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8))),
None => {
let px = rgba.map(unpre).unwrap_or([0, 0, 0, 0]);
(px.map(|v| format!("{v}")), rgba.map(|_| px))
}
};
let [tr_, tg, tb, ta] = texts;
row(&p, y, "R :", tr_, Color32::from_rgb(0xe0, 0x60, 0x60));
row(&p, y + 18.0, "G :", tg, Color32::from_rgb(0x60, 0xd0, 0x60));
row(&p, y + 36.0, "B :", tb, Color32::from_rgb(0x60, 0x90, 0xf0));
row(&p, y + 54.0, "A :", ta, t.text_dim);
let cx = rect.min.x + rect.width() * 0.5;
if let Some((x, yy)) = xy {
p.text(pos2(cx, y), Align2::LEFT_CENTER, format!("X : {x:.0}"), Tokens::mono(12.0), t.text);
p.text(pos2(cx, y + 18.0), Align2::LEFT_CENTER, format!("Y : {yy:.0}"), Tokens::mono(12.0), t.text);
}
if let Some(c) = rgba {
if let Some(px) = swatch {
let sw = egui::Rect::from_min_size(pos2(cx, y + 34.0), vec2(36.0, 30.0));
p.rect_filled(sw, 3.0, Color32::from_rgb(px[0], px[1], px[2]));
let _ = c;
}
y += 82.0;
p.line_segment([pos2(rect.min.x + 8.0, y), pos2(rect.max.x - 8.0, y)], egui::Stroke::new(1.0, t.separator));
+1
View File
@@ -352,6 +352,7 @@ fn phase_frame(app: &mut EffectcraftApp, v: &mut Vw<'_>) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k);
// A CPU frame goes up again when the magnification calls for another factor.
let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64);
// (Half-float frames have no such texture: the display pass makes mips.)
let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify);
if stale || refit {
show_frame(app, &ctx, k, img, minify);
+15 -3
View File
@@ -140,7 +140,8 @@ pub(super) fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect {
/// Put a rendered frame on screen: CPU pixels go into an egui texture, averaged down by `minify`
/// (see [`minify_factor`]); GPU frames are drawn straight from their wgpu texture (registered with
/// egui-wgpu, no readback).
/// egui-wgpu, no readback). Half-float frames (32 bpc) are kept for the display pass, which
/// `viewer_tools::draw_frame` runs (exposure, HDR step, display colour).
pub(super) fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage, minify: usize) {
use crate::frames::FrameImage;
// Settings ▸ Previews ▸ Viewer Zoom Quality.
@@ -158,8 +159,18 @@ pub(super) fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: cra
}
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key, minify)),
}
app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k, _)| (t.id(), *k));
app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k, _)| (Some(t.id()), *k));
app.viewer_image = Some(img);
app.viewer_f16 = None;
vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended));
}
FrameImage::CpuF16(f) => {
// Drawn through the display pass; the 8-bit texture of an earlier frame goes.
app.viewer_tex = None;
// No texture of its own: the display pass draws it.
app.viewer_shown = Some((None, key));
app.viewer_image = None;
app.viewer_f16 = Some(f);
vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended));
}
FrameImage::Gpu(f) => {
@@ -175,9 +186,10 @@ pub(super) fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: cra
};
drop(ren);
app.viewer_native = Some((id, key, f));
app.viewer_shown = Some((id, key));
app.viewer_shown = Some((Some(id), key));
// Pixels are read back on demand (viewer_pixels).
app.viewer_image = None;
app.viewer_f16 = None;
}
}
}
+139 -84
View File
@@ -343,14 +343,34 @@ pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> boo
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
}
/// Show Channel and exposure, then the display colour conversion (View ▸ Use Display Color
/// Management / Simulate Output).
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, dc: Option<vw::DisplayColor>) -> egui::ColorImage {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
if let Some(dc) = dc.filter(|_| matches!(ch, Channel::Rgb | Channel::RgbStraight)) {
dc.apply(&mut px);
/// The viewer's display pass for frames encoded as `encoding`: Show Channel, exposure, the HDR
/// step such frames still need, then the display colour conversion `dc` (View ▸ Use Display
/// Color Management / Simulate Output).
pub(crate) fn display_pass(app: &EffectcraftApp, encoding: vw::FrameEncoding, dc: Option<&vw::DisplayColor>) -> vw::DisplayPass {
let o = &app.session.state.viewer;
vw::DisplayPass::new(o.channel, o.colorized, o.exposure, encoding, dc)
}
/// How the project's 8-bit viewer frames are encoded (rendered with the HDR step).
pub(crate) fn rgba8_encoding(app: &EffectcraftApp) -> vw::FrameEncoding {
vw::FrameEncoding::of(&app.session.project.settings, false)
}
/// A key of a display pass's parameters, for the texture caches.
pub(crate) fn pass_key(pass: &vw::DisplayPass) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
crate::display_gpu::uniform(pass).hash(&mut h);
if let Some(vw::PassColor::Lut(l)) = &pass.color {
(std::sync::Arc::as_ptr(l) as usize).hash(&mut h);
}
vw::display_transform(&mut px, ch, colorized, stops);
h.finish()
}
/// The display pass on the CPU over an 8-bit frame.
fn transformed(img: &egui::ColorImage, pass: &vw::DisplayPass) -> egui::ColorImage {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
pass.apply_rgba8(&mut px);
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
}
@@ -362,9 +382,60 @@ pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String
+ &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management)
}
/// Draw `src` through `pass` into `rect`: the display pass on egui-wgpu's device (one texture per
/// slot, with mips), else on the CPU into a texture averaged down for the magnification (#417).
fn draw_through_pass(
app: &mut EffectcraftApp,
ctx: &egui::Context,
painter: &egui::Painter,
snapshot: bool,
src: crate::display_gpu::Source,
pass: &vw::DisplayPass,
rect: Rect,
) {
let smooth = app.session.prefs.viewer_zoom_smooth();
if let Some(gpu) = app.display_gpu()
&& let Some(rs) = &app.window_wgpu
{
let bake = if snapshot { &mut app.snapshot_bake } else { &mut app.viewer_bake };
if let Some(id) = bake.show(rs, &gpu, &src, pass, smooth) {
painter.image(id, rect, bake.uv(), Color32::WHITE);
return;
}
}
let size = src.size();
let minify = super::viewer::minify_factor(smooth, size[0] as f64, (rect.width() * ctx.pixels_per_point()) as f64);
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let ptr = match &src {
crate::display_gpu::Source::F16(f) => std::sync::Arc::as_ptr(f) as usize,
crate::display_gpu::Source::Rgba8(c) => std::sync::Arc::as_ptr(c) as usize,
};
(ptr, pass_key(pass), minify).hash(&mut h);
h.finish()
};
let id = egui::Id::unique(if snapshot { "viewer-snapshot-tex" } else { "viewer-display-tex" });
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let img = match &src {
crate::display_gpu::Source::F16(f) => f.to_color_image(pass),
crate::display_gpu::Source::Rgba8(c) => transformed(c, pass),
};
let img = crate::frames::fit_texture_by(img, crate::frames::max_texture_side(ctx), minify);
let tex = ctx.load_texture(if snapshot { "viewer-snapshot" } else { "viewer-display" }, img, super::viewer::zoom_texture_options(smooth));
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), rect, fitted_uv(ctx, size, minify), Color32::WHITE);
}
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through the display pass (Show
/// Channel, exposure, the HDR step of 32 bpc frames, display colour). Fast Previews ▸ Wireframe
/// draws layer outlines instead.
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
clear_sample_source(ctx);
let opts = app.session.state.viewer.clone();
@@ -391,47 +462,58 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
}
return;
}
// The snapshot went (Purge, a new one): its half floats and texture go too.
if app
.snapshot_f16
.as_ref()
.is_some_and(|(img, _)| app.session.snapshot.as_ref().is_none_or(|s| !std::ptr::eq(img.as_ptr(), std::sync::Arc::as_ptr(&s.image))))
{
app.snapshot_f16 = None;
app.snapshot_bake.free(app.window_wgpu.as_ref());
}
let dc = vw::DisplayColor::of(&app.session);
let dc_key = display_key(app, dc.is_some());
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none();
let display = SampleDisplay { channel: opts.channel, colorized: opts.colorized, exposure: opts.exposure, conversion: dc.clone() };
let region = shown_region(ctx, [ectx.comp.width as f32, ectx.comp.height as f32]);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
if snap {
let Some(s) = app.session.snapshot.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::unique("viewer-snapshot-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) };
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, opts);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
let pass = display_pass(app, s.encoding, dc.as_ref());
// The snapshot in half floats, made once (it holds the render's full range).
let f = match &app.snapshot_f16 {
Some((_, f)) => f.clone(),
None => {
let f = std::sync::Arc::new(crate::frames::F16Frame::from_image(&s.image, s.encoding));
app.snapshot_f16 = Some((std::sync::Arc::downgrade(&s.image), f.clone()));
f
}
};
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
note_sample_source(
ctx,
SampleSource::Snapshot(s.image.clone()),
[0.0, 0.0, ectx.comp.width as f64, ectx.comp.height as f64],
(!plain).then_some(display),
);
draw_through_pass(app, ctx, painter, true, crate::display_gpu::Source::F16(f), &pass, comp_rect);
note_sample_source(ctx, SampleSource::Snapshot(s.image.clone()), [0.0, 0.0, ectx.comp.width as f64, ectx.comp.height as f64], Some(pass));
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, crate::i18n::tr("Snapshot"), Tokens::ui(11.0), SNAP_COLOR);
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let roi_rect = || match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
};
// A half-float frame (32 bpc): always through the display pass, which applies the HDR step
// after the exposure.
if let Some(f) = app.viewer_f16.clone() {
if app.viewer_shown.is_none_or(|(_, k)| k.comp != cid.0) {
return;
}
let pass = display_pass(app, f.encoding, dc.as_ref());
draw_through_pass(app, ctx, painter, false, crate::display_gpu::Source::F16(f.clone()), &pass, roi_rect());
note_sample_source(ctx, SampleSource::F16(f), region, Some(pass));
return;
}
let pass = display_pass(app, rgba8_encoding(app), dc.as_ref());
let plain = pass.is_identity();
// A GPU frame (the web viewer): drawn straight from its texture; Show Channel / exposure
// read it back first (asynchronously in the browser: from the next frame on).
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0);
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == Some(n.0) && s.1 == n.1 && n.1.comp == cid.0);
let rect = if gpu {
let Some(id) = app.viewer_shown.map(|s| s.0) else { return };
let Some(id) = app.viewer_shown.and_then(|s| s.0) else { return };
if plain || app.viewer_pixels().is_none() {
if !plain {
ctx.request_repaint();
@@ -448,11 +530,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
if k.comp != cid.0 {
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let rect = match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
};
let rect = roi_rect();
if plain {
let uv = app.viewer_image.as_ref().map_or(uv, |src| fitted_uv(ctx, src.size, minify));
painter.image(tex.id(), rect, uv, Color32::WHITE);
@@ -464,51 +542,24 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
rect
};
let Some(src) = app.viewer_image.clone() else { return };
let smooth = app.session.prefs.viewer_zoom_smooth();
// Averaged down for the magnification, as the frame's own texture is (#417).
let minify = super::viewer::minify_factor(smooth, src.size[0] as f64, (rect.width() * ctx.pixels_per_point()) as f64);
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key, minify).hash(&mut h);
h.finish()
};
let id = egui::Id::unique("viewer-display-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let img =
crate::frames::fit_texture_by(transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), crate::frames::max_texture_side(ctx), minify);
let tex = ctx.load_texture("viewer-display", img, super::viewer::zoom_texture_options(smooth));
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), rect, fitted_uv(ctx, src.size, minify), Color32::WHITE);
note_sample_source(ctx, SampleSource::Cpu(src), region, Some(display));
draw_through_pass(app, ctx, painter, false, crate::display_gpu::Source::Rgba8(src.clone()), &pass, rect);
note_sample_source(ctx, SampleSource::Cpu(src), region, Some(pass));
}
#[derive(Clone)]
enum SampleSource {
Cpu(std::sync::Arc<egui::ColorImage>),
F16(std::sync::Arc<crate::frames::F16Frame>),
Gpu(crate::frames::FrameKey),
Snapshot(std::sync::Arc<effectcraft_engine::render::Image>),
}
#[derive(Clone)]
struct SampleDisplay {
channel: Channel,
colorized: bool,
exposure: f32,
conversion: Option<vw::DisplayColor>,
}
#[derive(Clone)]
struct DisplayedSample {
pass: u64,
source: SampleSource,
region: [f64; 4],
display: Option<SampleDisplay>,
display: Option<vw::DisplayPass>,
}
fn sample_source_id() -> egui::Id {
@@ -519,36 +570,40 @@ pub(crate) fn clear_sample_source(ctx: &egui::Context) {
ctx.data_mut(|data| data.remove::<DisplayedSample>(sample_source_id()));
}
fn note_sample_source(ctx: &egui::Context, source: SampleSource, region: [f64; 4], display: Option<SampleDisplay>) {
fn note_sample_source(ctx: &egui::Context, source: SampleSource, region: [f64; 4], display: Option<vw::DisplayPass>) {
let sample = DisplayedSample { pass: ctx.cumulative_pass_nr(), source, region, display };
ctx.data_mut(|data| data.insert_temp(sample_source_id(), sample));
}
/// Sample only a frame actually drawn this pass, with that draw's channel, exposure and
/// display conversion. Hidden and wireframe views deliberately have no sample source.
/// Sample only a frame actually drawn this pass, with that draw's display pass (channel,
/// exposure, HDR step and display conversion). Hidden and wireframe views deliberately have no
/// sample source.
pub(crate) fn sample_displayed(app: &EffectcraftApp, ctx: &egui::Context, point: [f64; 2]) -> Option<[f32; 4]> {
let sample = ctx.data(|data| data.get_temp::<DisplayedSample>(sample_source_id()))?;
if sample.pass != ctx.cumulative_pass_nr() {
return None;
}
let pixel = match sample.source {
SampleSource::Cpu(image) => super::fx_widgets::frame_pixel(&image, sample.region, point)?,
let rgba8 = |c: Color32| c.to_array().map(|v| v as f32 / 255.0);
// The frame's premultiplied pixel.
let raw: [f32; 4] = match sample.source {
SampleSource::Cpu(image) => rgba8(super::fx_widgets::frame_pixel(&image, sample.region, point)?),
SampleSource::F16(f) => f.pixel(super::fx_widgets::frame_pixel_index(f.size(), sample.region, point)?)?,
SampleSource::Gpu(key) => {
if app.viewer_shown.map(|(_, shown)| shown) != Some(key) {
return None;
}
super::fx_widgets::frame_pixel(app.viewer_image.as_ref()?, sample.region, point)?
rgba8(super::fx_widgets::frame_pixel(app.viewer_image.as_ref()?, sample.region, point)?)
}
SampleSource::Snapshot(image) => {
let index = super::fx_widgets::frame_pixel_index([image.width as usize, image.height as usize], sample.region, point)?;
crate::frames::display_pixel(*image.data.get(index)?)
*image.data.get(index)?
}
};
let image = egui::ColorImage::new([1, 1], vec![pixel]);
let image = match sample.display {
Some(display) => transformed(&image, display.channel, display.colorized, display.exposure, display.conversion),
None => image,
let shown = match &sample.display {
Some(pass) => pass.pixel(raw),
None => crate::frames::display_pixel(raw).to_array(),
};
let image = egui::ColorImage::new([1, 1], vec![Color32::from_rgba_premultiplied(shown[0], shown[1], shown[2], shown[3])]);
super::fx_widgets::sample_frame(&image, [0.0, 0.0, 1.0, 1.0], [0.5, 0.5])
}
+78 -19
View File
@@ -22,13 +22,25 @@ use crate::theme::Tokens;
pub enum PassiveTexture {
Cpu(egui::TextureHandle),
Gpu(egui::TextureId, Arc<effectcraft_gpu::DisplayFrame>),
/// A half-float frame (32 bpc) through the display pass on egui-wgpu's device.
Baked(Box<crate::display_gpu::Bake>),
}
impl PassiveTexture {
fn id(&self) -> egui::TextureId {
/// The texture to draw (`None`: the display pass has not made one).
fn id(&self) -> Option<egui::TextureId> {
match self {
PassiveTexture::Cpu(h) => h.id(),
PassiveTexture::Gpu(id, _) => *id,
PassiveTexture::Cpu(h) => Some(h.id()),
PassiveTexture::Gpu(id, _) => Some(*id),
PassiveTexture::Baked(b) => b.id(),
}
}
/// Where the frame is in the texture (a frame past the texture limit is averaged down).
fn uv(&self) -> Rect {
match self {
PassiveTexture::Baked(b) => b.uv(),
_ => Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
}
}
}
@@ -106,9 +118,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
let dkey = display_hash(&super::viewer_tools::display_key(app, dc.is_some()));
set_texture(app, &ctx, id, key, img, dc, dkey);
}
match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) {
Some((_, tex)) => {
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0).and_then(|(_, tex)| Some((tex.id()?, tex.uv()))) {
Some((tex, uv)) => {
p.image(tex, r, uv, Color32::WHITE);
}
None => {
let bg = c.background;
@@ -128,7 +140,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
}
/// Keep the frame viewer `id` shows in its texture.
fn set_texture(
pub(crate) fn set_texture(
app: &mut EffectcraftApp,
ctx: &egui::Context,
id: u32,
@@ -149,16 +161,19 @@ fn set_texture(
return;
}
ctx.data_mut(|d| d.insert_temp(dc_id, dkey));
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
// The monitor conversion the active viewer applies (Use Display Color Management).
let img = match (img, &dc) {
(FrameImage::Cpu(img), Some(dc)) => {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
dc.apply(&mut px);
let pixels = px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect();
FrameImage::Cpu(Arc::new(egui::ColorImage::new(img.size, pixels)))
let smooth = app.session.prefs.viewer_zoom_smooth();
let opts = super::viewer::zoom_texture_options(smooth);
let pass = |encoding| passive_pass(encoding, dc.as_ref());
let gpu = app.display_gpu();
let img = match img {
// Frames past the display texture limit take the CPU pass (display conversion first, then
// the averaging down), which the GPU bake leaves out.
FrameImage::CpuF16(f)
if app.window_wgpu.is_some() && gpu.as_ref().is_some_and(|g| !crate::display_gpu::Source::F16(f.clone()).needs_cpu_pass(g.max_side())) =>
{
FrameImage::CpuF16(f)
}
(img, _) => img,
img => cpu_passive_image(app, img, dc.as_ref()),
};
let tex = match (img, app.passive_tex.remove(&id)) {
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h))))
@@ -171,10 +186,23 @@ fn set_texture(
free(app, old.map(|(_, t)| t));
PassiveTexture::Cpu(crate::frames::load_fitted(ctx, format!("viewer-{id}"), (*img).clone(), opts))
}
(FrameImage::CpuF16(f), old) => {
let Some(gpu) = gpu else { return };
let mut bake = match old {
Some((_, PassiveTexture::Baked(b))) => b,
old => {
free(app, old.map(|(_, t)| t));
Box::default()
}
};
let Some(rs) = &app.window_wgpu else { return };
bake.show(rs, &gpu, &crate::display_gpu::Source::F16(f.clone()), &pass(f.encoding), smooth);
PassiveTexture::Baked(bake)
}
(FrameImage::Gpu(f), old) => {
let Some(rs) = &app.wgpu else { return };
let view = f.texture.create_view(&Default::default());
let sampler = || super::viewer::zoom_sampler(app.session.prefs.viewer_zoom_smooth());
let sampler = || super::viewer::zoom_sampler(smooth);
let mut ren = rs.renderer.write();
let tid = match old {
Some((_, PassiveTexture::Gpu(tid, _))) => {
@@ -189,6 +217,35 @@ fn set_texture(
app.passive_tex.insert(id, (key, tex));
}
/// A passive viewer's display pass: the monitor conversion the active viewer applies (Use
/// Display Color Management), with no channel or exposure of its own; for half-float frames
/// also the HDR step.
fn passive_pass(
encoding: effectcraft_engine::viewer::FrameEncoding,
dc: Option<&effectcraft_engine::viewer::DisplayColor>,
) -> effectcraft_engine::viewer::DisplayPass {
effectcraft_engine::viewer::DisplayPass::new(effectcraft_engine::viewer::Channel::Rgb, false, 0.0, encoding, dc)
}
/// A frame as a passive viewer shows it without the display pass on the GPU: CPU frames through
/// [`passive_pass`] (GPU frames unchanged).
pub(crate) fn cpu_passive_image(app: &EffectcraftApp, img: FrameImage, dc: Option<&effectcraft_engine::viewer::DisplayColor>) -> FrameImage {
match img {
FrameImage::Cpu(img) => {
let pass = passive_pass(super::viewer_tools::rgba8_encoding(app), dc);
if pass.is_identity() {
return FrameImage::Cpu(img);
}
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
pass.apply_rgba8(&mut px);
let pixels = px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect();
FrameImage::Cpu(Arc::new(egui::ColorImage::new(img.size, pixels)))
}
FrameImage::CpuF16(f) => FrameImage::Cpu(Arc::new(f.to_color_image(&passive_pass(f.encoding, dc)))),
img => img,
}
}
/// A hash of the display conversion's inputs (see `viewer_tools::display_key`).
fn display_hash(key: &str) -> u64 {
use std::hash::{Hash, Hasher};
@@ -199,8 +256,10 @@ fn display_hash(key: &str) -> u64 {
/// Give a passive viewer's texture back (GPU textures are registered with the renderer).
fn free(app: &EffectcraftApp, tex: Option<PassiveTexture>) {
if let (Some(PassiveTexture::Gpu(id, _)), Some(rs)) = (tex, &app.wgpu) {
rs.renderer.write().free_texture(&id);
match (tex, &app.wgpu) {
(Some(PassiveTexture::Gpu(id, _)), Some(rs)) => rs.renderer.write().free_texture(&id),
(Some(PassiveTexture::Baked(mut b)), _) => b.free(app.window_wgpu.as_ref()),
_ => {}
}
}
+39
View File
@@ -242,3 +242,42 @@ fn a_lost_device_draws_nothing_autosaves_and_closes() {
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}");
}
@@ -116,7 +116,7 @@ fn source(s: &Session, gpu: Option<Gpu>) -> RenderSource {
fn cpu_pixels(f: &FrameImage) -> Vec<egui::Color32> {
match f {
FrameImage::Cpu(c) => c.pixels.clone(),
FrameImage::Gpu(_) => panic!("expected a CPU frame"),
FrameImage::Gpu(_) | FrameImage::CpuF16(_) => panic!("expected an 8-bit CPU frame"),
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ fn viewer_frames_render_remotely_from_project_diffs() {
assert_eq!(frames.remote_busy(), 0);
let img = match frames.get(&key(&s, 10)).expect("viewer frame cached") {
FrameImage::Cpu(c) => c,
FrameImage::Gpu(_) => unreachable!(),
FrameImage::Gpu(_) | FrameImage::CpuF16(_) => unreachable!(),
};
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
assert_eq!(img.size, local.size);
+22 -1
View File
@@ -220,12 +220,33 @@ instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry
sampled trilinearly, CPU frames go up averaged down by the whole factor that leaves about one
texel per screen pixel.
32 bpc projects keep **half-float frames** on the desktop (`frames::F16Frame`, 8 bytes a pixel:
the RAM preview holds half as many as of 8-bit frames). They are rendered without Project
Settings ▸ HDR (`RenderOpts::skip_hdr_step`), so the values above 1.0 stay. The viewer's
**display pass** (`engine::viewer::DisplayPass`; on the GPU `ui-egui/src/display_gpu.rs` +
`display_gpu.wgsl`) then turns a frame into what the panel shows, in this order: decode with the
output space's curve, the viewer's exposure in linear light, the HDR step the frame was rendered
without (Compand / Tone Map), encode, clamp to 0..1, the display colour conversion (Use Display
Color Management, Simulate Output, LUT profiles), Show Channel, premultiply, round to 8 bits.
So exposure reaches the full float range, and tone mapping and the monitor conversion see the
exposed light. The pass runs on egui-wgpu's device (it needs no GPU compositor) into one RGBA8
texture with mips per viewer slot (main viewer, snapshot, each passive viewer: about 110 MB of
video memory per slot for a 4K frame, input included); without a device the same steps run on
the CPU (`DisplayPass::pixel`, which is also the shader's test reference). 8 and 16 bpc frames
stay 8-bit with the HDR step applied when rendered; they go through the same pass when exposure,
a channel view or a display conversion applies, else they are drawn as they are. Panels that
read the viewer's pixels as 8-bit (Video Preview, keyers, thumbnails) get the half-float frame
converted on demand with the HDR step and without the exposure; the Info panel shows the
frame's float values. The browser keeps 8-bit frames for now (its frame workers send RGBA8).
A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both
the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every
preview frame are written (LZ4, atomic rename, checksummed) under 128-bit content keys — the
comp's content hash (the comp, what it uses, footage file size and time) plus frame, scale and
view for frames; the layer key salted with the footage fingerprint for layers — so entries
survive restarts and never go stale. LRU eviction keeps it under the size limit; Edit ▸ Purge
survive restarts and never go stale. Frame entries are 8-bit RGBA, or for 32 bpc viewer frames
half floats with their bytes shuffled into planes before LZ4 (the entry header's format byte
tells them apart; the render options in the key keep the two kinds apart). LRU eviction keeps it under the size limit; Edit ▸ Purge
empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue. In the
browser the frames live in the Origin Private File System: frame workers write them, the page
keeps the index (`DiskIndex`) and reads hits ([web.md](web.md#disk-cache)).