mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 18:31:30 -06:00
* Viewer exposure on the full float range of 32 bpc frames (#2797) 32 bpc viewer frames on the desktop are rendered without the HDR step (skip_hdr_step, #2916) and kept as half floats in the RAM preview. A display pass on egui-wgpu's device turns each frame into one RGBA8 texture with mips per viewer, in this order: decode, exposure in linear light, Compand / Tone Map, encode, display colour, Show Channel, rounding. Before, exposure was an 8-bit lookup applied after tone mapping, clamping and the display conversion, so -2 EV could not bring values above 1.0 back. - engine: viewer::DisplayPass describes the pass as data; its pixel() is the CPU fallback (no device) and the shader's reference. It replaces exposure_lut / display_transform. FrameEncoding records a frame's curve and the HDR step it still needs. 32 bpc snapshots are taken without the HDR step; snapshot_rgba8 applies it. - ui-egui: FrameImage::CpuF16 (half crate, F16C at run time), byte accounting at 8 bytes a pixel in the cache and the prefetch estimates; display_gpu.rs + display_gpu.wgsl; main viewer, snapshot and passive viewers draw through the pass; the 8-bit image other panels read is made on demand with the HDR step; the Info panel shows float values. - Half-float frames are not written to the disk cache yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Viewer display pass: f16 disk cache entries, window-device gating, review fixes - Disk cache: half-float frame entries (format byte in the entry header, byte planes + LZ4); 8-bit entries keep format 0 and stay valid. 32 bpc viewer frames are written and read again, and the timeline's disk bar sees them. A damaged half-float entry is deleted. - The display pass runs on the window's egui-wgpu device, with or without the GPU compositor (still building, failed, GL fallback). Its pipelines are made inside error scopes; a backend that rejects them gets the CPU pass instead of a blank viewer. - A new LUT of the same size (another ICC profile, Preserve RGB, another display) runs the pass again. - f32 to f16: one pass per L1-sized block, the NaN / infinity fix-up on the bits, no fill of the buffer (4K: 4.7 ms, as the 8-bit conversion). - Mips of odd-sized levels include the last column / row; the snapshot's half floats and texture go with the snapshot; no placeholder texture ids; u64 size checks. - Tests: LUT profiles and negative values against the shader, Bake reruns, the passive viewer's colour, f16 disk round trips; exact comparisons allow 1 LSB on software rasterizers. Docs: the display pass and the f16 disk entries in architecture.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Viewer f16 disk-cache test: temp dir removed on failure too, wait for the queued disk write Review points: the test's directory was only removed by its last statement (now tempfile::TempDir), and the frame is released before its disk write is queued, so flush() could return early (now waits for the entry). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Viewer: oversized half-float frames use the CPU display pass, so exposure/HDR come before the averaging down Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Viewer: fmt and a debug assert for the half-float upload path Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Disk cache: persist_missing also writes half-float frames Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Passive viewer: oversized half-float frames take the CPU display pass instead of leaving a stale texture Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
3.5 KiB
Rust
92 lines
3.5 KiB
Rust
//! 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)
|
|
}
|
|
}
|