From d8d648cb72ddf157306b37658a1af7c212f1c4c3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 23:30:53 -0400 Subject: [PATCH] GPU canvas: Rgba16Float display texture for 16/32-bit documents (#46) (#84) * engine: display LUT without the 32-bit preview for the GPU canvas shader; keep values above 1.0 in texture_buffer ColorState::gpu_canvas_lut leaves View > 32-bit Preview Options out of the display LUT (the canvas shader applies exposure/gamma before it, on unclipped float texels), and ColorState::hdr_preview returns the active preview. CanvasDisplay::texture_buffer no longer clamps linear values to 1.0 before sRGB-encoding them (the 8-bit path clamps afterwards with the same result). Refs #46 Co-Authored-By: Claude Opus 5.5 * GPU canvas: Rgba16Float display texture for 16/32-bit documents (#46) 16- and 32-bit documents get an Rgba16Float canvas texture when the adapter can sample, filter, render to and copy it (Rgba8Unorm stays for 8-bit documents, as the fallback, and for high-bit documents over 100 MP, about 1.07 GB with mips), so smooth gradients no longer band through 8 bits. Both the wgpu compositor path (encode pass with 16F pipelines; no upper clamp) and the CPU band/rect upload path (f32 -> half conversion, reused band buffer) write it. The 32-bit preview (exposure/gamma) moves into the canvas shader, before the colour-management/proof LUT, so values above 1.0 are exposed into range instead of clipped. GpuCanvas::read_texels / texture_info for tests and benchmarks; PHOTOCRAFT_CANVAS_F16=0/1 disables the float canvas / ignores the budget. large_image_bench gains --depth 16|32. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/color_cmds.rs | 31 +- crates/engine/src/display_color.rs | 6 +- crates/engine/src/proof_sim.rs | 11 +- crates/ui-egui/examples/large_image_bench.rs | 12 + crates/ui-egui/src/canvas.rs | 17 +- crates/ui-egui/src/gpu_canvas.rs | 425 ++++++++++++++++--- crates/ui-egui/tests/canvas_16f.rs | 250 +++++++++++ 7 files changed, 680 insertions(+), 72 deletions(-) create mode 100644 crates/ui-egui/tests/canvas_16f.rs diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index e7d6a3e0..b5520919 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -321,25 +321,48 @@ impl ColorState { /// The display transform as an `size³` RGBA 3D LUT (upload with /// [`Lut3d::to_rgba16f_bytes`] and apply in the canvas shader). pub fn display_lut(&self, doc: &Document, size: usize) -> Result { - if let Some(lut) = crate::proof_sim::display_lut(self, doc, size)? { + self.display_lut_with(doc, size, true) + } + + /// [`ColorState::display_lut`], with or without the 32-bit preview (exposure/gamma). + fn display_lut_with(&self, doc: &Document, size: usize, hdr: bool) -> Result { + if let Some(lut) = crate::proof_sim::display_lut_with(self, doc, size, hdr)? { return Ok(lut); } let t = self.display_transform(doc)?; Ok(Lut3d::from_transform(&t, size)) } + /// View › 32-bit Preview Options of `doc` when they change the display (a 32-bit document + /// with a non-default exposure or gamma). + pub fn hdr_preview(&self, doc: &Document) -> Option { + if crate::proof_sim::hdr_active(self, doc) { self.hdr.get(&doc.id).copied() } else { None } + } + /// What the canvas should do for `doc`: `None` when the canvas values go to the screen /// unchanged (the document's display profile matches the monitor and neither Proof Colors, /// Gamut Warning nor a 32-bit preview is on), else an RGBA8 display LUT (`size`³, red fastest) /// mapping canvas texture values to the monitor, whose alpha is 255 where the colour is out /// of the proof gamut (only with Gamut Warning on). pub fn canvas_lut(&self, doc: &Document, size: usize) -> Result>> { + self.canvas_lut_with(doc, size, true) + } + + /// [`ColorState::canvas_lut`] without the 32-bit preview, which the GPU canvas shader applies + /// itself (see [`ColorState::hdr_preview`]) so that 32-bit values above 1.0, kept by its float + /// texture, are exposed into range rather than clipped by the LUT's 0..1 domain. + pub fn gpu_canvas_lut(&self, doc: &Document, size: usize) -> Result>> { + self.canvas_lut_with(doc, size, false) + } + + fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool) -> Result>> { + let size = size.max(2); let pv = self.proof(doc.id); let display = self.canvas_display(doc)?; - if !pv.enabled && !pv.gamut_warning && !crate::proof_sim::hdr_active(self, doc) { - return Ok(display.transform.as_ref().map(|t| Lut3d::from_transform(t, size.max(2)).to_rgba8())); + if !pv.enabled && !pv.gamut_warning && !(hdr && crate::proof_sim::hdr_active(self, doc)) { + return Ok(display.transform.as_ref().map(|t| Lut3d::from_transform(t, size).to_rgba8())); } - let lut = self.display_lut(doc, size)?; + let lut = self.display_lut_with(doc, size, hdr)?; let mut bytes = lut.to_rgba8(); let check = if pv.gamut_warning { Some(GamutCheck::new(&display.source, &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None }; let s = (size - 1) as f32; diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index b77a23a0..d5c6d2d4 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -66,7 +66,8 @@ impl CanvasDisplay { } /// GPU canvas: a CPU composite as the texture stores it (sRGB-encoded for linear - /// composites; the display LUT does the rest). + /// composites; the display LUT does the rest). Values above 1.0 are encoded too (the sRGB + /// curve extended), for the float canvas texture of 32-bit documents. pub fn texture_buffer<'a>(&self, buf: &'a Buffer) -> Cow<'a, Buffer> { if !self.encode_srgb { return Cow::Borrowed(buf); @@ -74,7 +75,8 @@ impl CanvasDisplay { let mut b = buf.clone(); for p in &mut b.px { for v in &mut p[..3] { - *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 1.0)); + // Capped at the largest half float; NaN is dropped by the texel conversion. + *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); } } Cow::Owned(b) diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index 6f13c0a5..f5aa4de9 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -146,9 +146,15 @@ pub fn dichromat(rgb_lin: [f32; 3], protan: bool) -> [f32; 3] { /// The display LUT when a simulation or 32-bit preview applies (None = plain profile proof). pub fn display_lut(c: &ColorState, doc: &Document, size: usize) -> Result> { + display_lut_with(c, doc, size, true) +} + +/// [`display_lut`], leaving the 32-bit preview out when `include_hdr` is false (the GPU canvas +/// applies it in its shader). +pub fn display_lut_with(c: &ColorState, doc: &Document, size: usize, include_hdr: bool) -> Result> { let pv = c.proof(doc.id); let kind = if pv.enabled { pv.setup.kind } else { ProofKind::Profile }; - let hdr = if hdr_active(c, doc) { c.hdr.get(&doc.id).copied() } else { None }; + let hdr = if include_hdr { c.hdr_preview(doc) } else { None }; if kind == ProofKind::Profile && hdr.is_none() { return Ok(None); } @@ -423,6 +429,9 @@ mod tests { let q = sample(&lut, [1, 1, 1]); assert!((q[0] - 0.354).abs() < 0.02, "{q:?}"); assert!(s.color.canvas_lut(&d, 5).unwrap().is_some()); + // The GPU canvas applies it in its shader: its LUT leaves it out (none for sRGB). + assert_eq!(s.color.hdr_preview(&d), Some(HdrPreview { highlight_compression: false, exposure: 1.0, gamma: 1.0 })); + assert!(s.color.gpu_canvas_lut(&d, 5).unwrap().is_none()); // Highlight Compression: identity mapping, so no LUT. s.execute("view.thirtyTwoBitPreviewOptions", json!({"method": "highlightCompression"})).unwrap(); assert!(!hdr_active(&s.color, &d)); diff --git a/crates/ui-egui/examples/large_image_bench.rs b/crates/ui-egui/examples/large_image_bench.rs index 3b22de00..3b08640f 100644 --- a/crates/ui-egui/examples/large_image_bench.rs +++ b/crates/ui-egui/examples/large_image_bench.rs @@ -13,6 +13,8 @@ //! //! The source JPEG is generated once and cached (`--cache `, default the system temp dir). //! `--op open` is the baseline every other operation includes. `--cpu` skips the GPU. +//! `--depth 16` or `--depth 32` converts the document to that bit depth after opening (the +//! conversion is part of "open"), e.g. a 36 MP 16-bit refresh: `--size 7360x4912 --depth 16 --op refresh`. use std::time::Instant; @@ -109,11 +111,21 @@ fn main() { drop(bytes); let mut s = Session::new(); s.open_document(doc, Some("photo.jpg".into())); + match arg(&args, "--depth").as_deref() { + Some("16") => exec(&mut s, "image.mode.bits16", json!({})), + Some("32") => exec(&mut s, "image.mode.bits32", json!({})), + _ => {} + } let t_open = ms(t); let t = Instant::now(); let path = refresh(gpu, &s, true); println!("open (decode + document) {t_open:>9.0} ms"); println!("first refresh ({path:<8}) {:>9.0} ms", ms(t)); + if let Some((g, _)) = gpu + && let Some((format, bytes)) = g.texture_info(s.active().expect("doc").doc.id.0) + { + println!("canvas texture {format:?}, {} MB with mips", bytes >> 20); + } let doc = || s.active().expect("doc").doc.clone(); match op.as_str() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d0a150fd..6a925860 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -458,7 +458,7 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6 let buf = photocraft_compose::flatten(r); let t1 = crate::gpu_canvas::now_ms(); let (display, _) = canvas_display(app, &doc); - app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf)); + app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); } app.filter_preview = Some(crate::filter_dialog::FilterPreview { doc: doc_id, revision, hash, k, result }); @@ -498,7 +498,7 @@ fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64 let buf = photocraft_compose::flatten(&p); let t1 = crate::gpu_canvas::now_ms(); let (display, _) = canvas_display(app, &doc); - app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf)); + app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); app.perf.record("proxy", p.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); app.proxy_uploaded = Some((doc_id, hash)); } @@ -763,8 +763,8 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { const DISPLAY_LUT: usize = 33; /// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with -/// `doc`'s colour management: document → monitor profile, View › Proof Colors / Gamut Warning -/// and 32-bit preview. Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on +/// `doc`'s colour management: document → monitor profile and View › Proof Colors / Gamut +/// Warning (the 32-bit preview is applied by the canvas shader, see [`hdr_preview`]). Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on /// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning). fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocraft_doc::Document, key: u64) -> u8 { let Some(gpu) = app.gpu.clone() else { return 0 }; @@ -777,7 +777,7 @@ fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocra return mode; } let gamut = app.session.color.proof(doc.id).gamut_warning; - let mode = match app.session.color.canvas_lut(doc, DISPLAY_LUT) { + let mode = match app.session.color.gpu_canvas_lut(doc, DISPLAY_LUT) { Ok(Some(bytes)) => { gpu.set_display_lut(key, DISPLAY_LUT as u32, Some(&bytes)); if gamut { 2 } else { 1 } @@ -796,6 +796,11 @@ fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocra mode } +/// View › 32-bit Preview Options for the GPU canvas shader: (exposure, gamma) when active. +fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f32; 2]> { + app.session.color.hdr_preview(doc).map(|h| [h.exposure, h.gamma]) +} + /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); @@ -849,6 +854,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: pixel_grid: false, view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), display: sync_display_lut(app, &ctx, &doc, key), + hdr: hdr_preview(app, &doc), }; crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); } else if !flip && ensure_gpu(app, idx) { @@ -867,6 +873,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: pixel_grid, view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(), display: sync_display_lut(app, &ctx, &doc, doc.id.0), + hdr: hdr_preview(app, &doc), }; crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); } else { diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index bebd3e2b..f97c2c2d 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1,11 +1,15 @@ //! GPU document canvas: an `egui_wgpu` paint callback that draws one document view with a custom //! WGSL shader. //! -//! The document's composite lives on the GPU as one or more mipmapped `Rgba8Unorm` textures -//! (premultiplied alpha, sRGB-encoded values, matching how the CPU path and egui blend). Uploads -//! are either full (new document, size change, re-render) or a damage rect (brush strokes); after -//! an upload only the affected region of each mip level is regenerated by a render-pass blit chain. -//! Documents larger than the device's maximum texture size are split into tiles. +//! The document's composite lives on the GPU as one or more mipmapped textures (premultiplied +//! alpha, sRGB-encoded values, matching how the CPU path and egui blend): `Rgba8Unorm` for 8-bit +//! documents, `Rgba16Float` for 16- and 32-bit documents when the adapter can render to and +//! filter it (so smooth gradients don't band and 32-bit values above 1.0 survive for the 32-bit +//! preview), within [`F16_BUDGET_PX`]; larger high-bit documents fall back to `Rgba8Unorm`. +//! Uploads are either full (new document, size change, re-render) or a damage rect (brush +//! strokes); after an upload only the affected region of each mip level is regenerated by a +//! render-pass blit chain. Documents larger than the device's maximum texture size are split +//! into tiles. //! //! Per frame the shader draws, in *device pixels*: //! - a soft analytic drop shadow around the document on the pasteboard (erf-blurred box), @@ -30,6 +34,13 @@ use eframe::wgpu::util::DeviceExt as _; /// Default tile side; documents up to this size (and the device limit) use a single texture. pub const DEFAULT_TILE: u32 = 8192; const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +/// Canvas texture format of high-bit documents (see [`GpuCanvas::format_for`]). +const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +/// Largest high-bit document (in pixels) given an `Rgba16Float` canvas texture: 100 MP is +/// 800 MB at level 0, about 1.07 GB with mips (twice the `Rgba8Unorm` cost). Bigger 16/32-bit +/// documents (a 14000² one would need 2.1 GB) use `Rgba8Unorm`, so they stay on the GPU like +/// 8-bit ones. `PHOTOCRAFT_CANVAS_F16=0` turns the float canvas off, `=1` ignores this budget. +pub const F16_BUDGET_PX: u64 = 100_000_000; const VIEW_UNIFORM_SIZE: u64 = 112; const VIEW_FLOATS: usize = 28; const TILE_UNIFORM_SIZE: u64 = 16; @@ -54,6 +65,21 @@ pub struct ViewParams { /// Display transform: 0 none, 1 the document's display LUT (document → monitor profile, /// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]). pub display: u8, + /// View › 32-bit Preview Options (exposure in stops, gamma), applied to the texture values + /// before the display LUT; `None` when off. The display LUT must then leave it out + /// (`ColorState::gpu_canvas_lut`). + pub hdr: Option<[f32; 2]>, +} + +/// When high-bit documents get an `Rgba16Float` canvas texture. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HighPolicy { + /// The adapter can't render to or filter `Rgba16Float`, or `PHOTOCRAFT_CANVAS_F16=0`. + Off, + /// Up to [`F16_BUDGET_PX`]. + Budget, + /// Always (`PHOTOCRAFT_CANVAS_F16=1`). + Always, } /// Uploads documents to the GPU and paints them. Cheap to clone. @@ -61,6 +87,15 @@ pub struct ViewParams { pub struct GpuCanvas { rs: RenderState, tile: u32, + high: HighPolicy, +} + +/// Can `adapter` use `Rgba16Float` as a sampled, filtered, render-target and copy texture? +/// (Core WebGPU guarantees it; WebGL2 needs `EXT_color_buffer_float`, and some drivers lack it.) +pub fn supports_f16_canvas(adapter: &wgpu::Adapter) -> bool { + let f = adapter.get_texture_format_features(FORMAT_HIGH); + let usages = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC; + f.allowed_usages.contains(usages) && f.flags.contains(wgpu::TextureFormatFeatureFlags::FILTERABLE) } impl GpuCanvas { @@ -74,10 +109,42 @@ impl GpuCanvas { let max = rs.device.limits().max_texture_dimension_2d; let env_tile = std::env::var("PHOTOCRAFT_GPU_TILE").ok().and_then(|v| v.parse::().ok()); let tile = env_tile.or(tile).unwrap_or(DEFAULT_TILE).clamp(64, max); - let res = Resources::new(&rs.device, &rs.queue, rs.target_format); + let high = match std::env::var("PHOTOCRAFT_CANVAS_F16").ok().as_deref() { + _ if !supports_f16_canvas(&rs.adapter) => HighPolicy::Off, + Some("0") => HighPolicy::Off, + Some("1") => HighPolicy::Always, + _ => HighPolicy::Budget, + }; + let res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off); rs.renderer.write().callback_resources.insert(res); - log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}", rs.target_format); - Self { rs: rs.clone(), tile } + log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format); + Self { rs: rs.clone(), tile, high } + } + + /// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit + /// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else + /// `Rgba8Unorm`. + pub fn format_for(&self, depth: photocraft_doc::SampleType, size: [u32; 2]) -> wgpu::TextureFormat { + let px = size[0] as u64 * size[1] as u64; + let high = match self.high { + HighPolicy::Off => false, + HighPolicy::Budget => px <= F16_BUDGET_PX, + HighPolicy::Always => true, + }; + if high && depth != photocraft_doc::SampleType::U8 { FORMAT_HIGH } else { FORMAT } + } + + /// Format and GPU bytes (all tiles, all mip levels) of document `key`'s canvas texture. + pub fn texture_info(&self, key: u64) -> Option<(wgpu::TextureFormat, u64)> { + let r = self.rs.renderer.read(); + let d = r.callback_resources.get::()?.docs.get(&key)?; + let bpp = texel_bytes(d.format); + let bytes = d + .tiles + .iter() + .map(|t| (0..t.levels.len() as u32).map(|l| ((t.rect[2] >> l).max(1) as u64) * ((t.rect[3] >> l).max(1) as u64) * bpp).sum::()) + .sum(); + Some((d.format, bytes)) } /// Set the checkerboard and gamut warning colours. @@ -94,56 +161,73 @@ impl GpuCanvas { r.callback_resources.get::().and_then(|res| res.docs.get(&doc)).is_some_and(|d| d.size == size) } - /// Replace the whole document image. `rgba` is premultiplied RGBA8, `size[0] * size[1] * 4` bytes. + /// Replace the whole document image with an `Rgba8Unorm` texture. `rgba` is premultiplied + /// RGBA8, `size[0] * size[1] * 4` bytes (anything else is ignored). pub fn upload_full(&self, doc: u64, size: [u32; 2], rgba: &[u8]) { - assert_eq!(rgba.len(), size[0] as usize * size[1] as usize * 4, "upload_full: buffer size mismatch"); - if size[0] == 0 || size[1] == 0 { + self.upload_full_as(doc, size, FORMAT, rgba); + } + + /// Replace the whole document image with a `format` texture of premultiplied `texels`. + fn upload_full_as(&self, doc: u64, size: [u32; 2], format: wgpu::TextureFormat, texels: &[u8]) { + if size[0] == 0 || size[1] == 0 || texels.len() as u64 != size[0] as u64 * size[1] as u64 * texel_bytes(format) { return; } let (device, queue) = (&self.rs.device, &self.rs.queue); let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return }; - if res.docs.get(&doc).is_none_or(|d| d.size != size) { - let tex = DocTextures::new(device, res, size, self.tile); + if res.docs.get(&doc).is_none_or(|d| d.size != size || d.format != format) { + let tex = DocTextures::new(device, res, size, self.tile, format); res.docs.insert(doc, tex); } - let d = &res.docs[&doc]; - d.write(queue, [0, 0], size, size[0], rgba); + let Some(d) = res.docs.get(&doc) else { return }; + d.write(queue, [0, 0], size, size[0], texels); d.regenerate_mips(device, queue, res, [0, 0, size[0], size[1]]); } - /// Replace a rectangle `origin..origin+size` of an uploaded document with premultiplied RGBA8 - /// pixels (`size[0] * size[1] * 4` bytes). Returns false if the document isn't uploaded. - pub fn upload_rect(&self, doc: u64, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) -> bool { - assert_eq!(rgba.len(), size[0] as usize * size[1] as usize * 4, "upload_rect: buffer size mismatch"); + /// Replace a rectangle `origin..origin+size` of an uploaded document with premultiplied texels + /// in its texture format (`size[0] * size[1]` texels: RGBA8 or RGBA16F). Returns false if the + /// document isn't uploaded, the rectangle doesn't fit or the buffer has the wrong size. + pub fn upload_rect(&self, doc: u64, origin: [u32; 2], size: [u32; 2], texels: &[u8]) -> bool { let (device, queue) = (&self.rs.device, &self.rs.queue); let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return false }; let Some(d) = res.docs.get(&doc) else { return false }; - if origin[0] + size[0] > d.size[0] || origin[1] + size[1] > d.size[1] { + if texels.len() as u64 != size[0] as u64 * size[1] as u64 * texel_bytes(d.format) + || origin[0].saturating_add(size[0]) > d.size[0] + || origin[1].saturating_add(size[1]) > d.size[1] + { return false; } if size[0] == 0 || size[1] == 0 { return true; } - d.write(queue, origin, size, size[0], rgba); + d.write(queue, origin, size, size[0], texels); d.regenerate_mips(device, queue, res, [origin[0], origin[1], origin[0] + size[0], origin[1] + size[1]]); true } - /// Upload a CPU composite covering the whole document. - pub fn upload_buffer_full(&self, doc: u64, buf: &photocraft_compose::Buffer) { + /// The texture format of uploaded document `doc`. + fn format_of(&self, doc: u64) -> Option { + let r = self.rs.renderer.read(); + r.callback_resources.get::().and_then(|res| res.docs.get(&doc)).map(|d| d.format) + } + + /// Upload a CPU composite covering the whole document (a preview of a document of `depth`: + /// see [`GpuCanvas::format_for`]). + pub fn upload_buffer_full(&self, doc: u64, buf: &photocraft_compose::Buffer, depth: photocraft_doc::SampleType) { let size = [buf.rect.width(), buf.rect.height()]; - self.upload_full(doc, size, &premultiply_rgba8(&buf.px)); + let format = self.format_for(depth, size); + self.upload_full_as(doc, size, format, &texels(format, &buf.px)); } /// Upload a CPU composite of a sub-rectangle (in document pixels, `buf.rect`). pub fn upload_buffer_rect(&self, doc: u64, buf: &photocraft_compose::Buffer) -> bool { let r = buf.rect; + let Some(format) = self.format_of(doc) else { return false }; if r.x0 < 0 || r.y0 < 0 { return false; } - self.upload_rect(doc, [r.x0 as u32, r.y0 as u32], [r.width(), r.height()], &premultiply_rgba8(&buf.px)) + self.upload_rect(doc, [r.x0 as u32, r.y0 as u32], [r.width(), r.height()], &texels(format, &buf.px)) } /// Composite `region` of `doc` with the wgpu compositor straight into its display texture @@ -183,18 +267,24 @@ impl GpuCanvas { }, }; let key = doc.id.0; - let fresh = res.docs.get(&key).is_none_or(|d| d.size != size); + let format = self.format_for(doc.depth, size); + let fresh = res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format); let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) }; if let Err(e) = comp.supports(doc) { res.compositor = Some(comp); return Err(e); } if fresh { - let tex = DocTextures::new(device, res, size, self.tile); + let tex = DocTextures::new(device, res, size, self.tile, format); res.docs.insert(key, tex); } - let d = &res.docs[&key]; - let (bgl, pipe) = (&res.encode_bgl, &res.encode_pipeline); + let (Some(pipe), Some(d)) = (res.encode_pipeline(format), res.docs.get(&key)) else { + res.compositor = Some(comp); + return Err(photocraft_gpu::Unsupported(format!("no {format:?} canvas texture or pipeline"))); + }; + let bgl = &res.encode_bgl; + // A float canvas keeps values above 1.0 (32-bit documents, for the 32-bit preview). + let keep_hdr = i32::from(format == FORMAT_HIGH); let result = comp.render(device, queue, doc, region, |enc, out| { for t in &d.tiles { let [tx, ty, tw, th] = t.rect.map(|v| v as i32); @@ -202,7 +292,7 @@ impl GpuCanvas { if r.is_empty() { continue; } - let offset = [out.rect.x0 - tx, out.rect.y0 - ty, i32::from(encode_srgb), 0]; + let offset = [out.rect.x0 - tx, out.rect.y0 - ty, i32::from(encode_srgb), keep_hdr]; let ubuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pc_encode"), contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::>(), @@ -262,7 +352,8 @@ impl GpuCanvas { ) -> Refresh { let bounds = doc.bounds(); let size = [doc.size.width, doc.size.height]; - let damage = damage.filter(|_| self.has(key, size)).map(|r| r.intersect(&bounds)); + let format = self.format_for(doc.depth, size); + let damage = damage.filter(|_| self.has(key, size) && self.format_of(key) == Some(format)).map(|r| r.intersect(&bounds)); let t0 = now_ms(); let mut out = Refresh::default(); let region = damage.unwrap_or(bounds); @@ -302,23 +393,25 @@ impl GpuCanvas { /// Composite the whole of `doc` with the CPU compositor into document `key`'s texture, band by /// band (each band converted and uploaded before the next is rendered), so a huge document - /// never needs a full-size float composite or RGBA8 copy in memory. + /// never needs a full-size float composite or texture-format copy in memory. pub fn upload_composite(&self, key: u64, doc: &photocraft_doc::Document, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { let size = [doc.size.width, doc.size.height]; if size[0] == 0 || size[1] == 0 { return; } + let format = self.format_for(doc.depth, size); let (device, queue) = (&self.rs.device, &self.rs.queue); { let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return }; - if res.docs.get(&key).is_none_or(|d| d.size != size) { - let tex = DocTextures::new(device, res, size, self.tile); + if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { + let tex = DocTextures::new(device, res, size, self.tile, format); res.docs.insert(key, tex); } } + let mut rgba = Vec::new(); let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { - let rgba = premultiply_rgba8(&texture_buffer(display, &band).px); + texels_into(format, &texture_buffer(display, &band).px, &mut rgba); let renderer = self.rs.renderer.read(); let d = renderer.callback_resources.get::().and_then(|r| r.docs.get(&key)).ok_or(())?; d.write(queue, [0, band.rect.y0 as u32], [size[0], band.rect.height()], size[0], &rgba); @@ -364,13 +457,61 @@ impl GpuCanvas { res.luts.remove(&doc); } Some(bytes) => { - assert_eq!(bytes.len(), (size * size * size * 4) as usize, "set_display_lut: buffer size mismatch"); + if size < 2 || bytes.len() as u64 != (size as u64).pow(3) * 4 { + log::warn!("set_display_lut: {} bytes for a {size}³ LUT; ignored", bytes.len()); + return; + } let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes); res.luts.insert(doc, bg); } } } + /// Read back level 0 of document `key`'s canvas texture: its format, size and the stored + /// (premultiplied) texels as `f32` RGBA, row-major. Waits for the GPU; for tests, benchmarks + /// and debugging. + #[cfg(not(target_arch = "wasm32"))] + pub fn read_texels(&self, key: u64) -> Option<(wgpu::TextureFormat, [u32; 2], Vec<[f32; 4]>)> { + let (device, queue) = (&self.rs.device, &self.rs.queue); + let renderer = self.rs.renderer.read(); + let d = renderer.callback_resources.get::()?.docs.get(&key)?; + let bpp = texel_bytes(d.format); + let (w, h) = (d.size[0] as usize, d.size[1] as usize); + let mut out = vec![[0.0f32; 4]; w.checked_mul(h)?]; + for t in &d.tiles { + let [tx, ty, tw, th] = t.rect; + let row = (tw as u64 * bpp).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as u64); + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_canvas_readback"), + size: row * th as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_canvas_readback") }); + enc.copy_texture_to_buffer( + t.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row as u32), rows_per_image: Some(th) }, + }, + wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 }, + ); + queue.submit([enc.finish()]); + let slice = buf.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).ok()?; + let data = slice.get_mapped_range().ok()?; + for y in 0..th as usize { + let src = data.get(y * row as usize..)?; + for x in 0..tw as usize { + let o = out.get_mut((ty as usize + y) * w + tx as usize + x)?; + *o = texel_to_f32(d.format, src.get(x * bpp as usize..(x + 1) * bpp as usize)?); + } + } + } + Some((d.format, d.size, out)) + } + /// Add the paint callback drawing `params` into `rect` (clipped by the painter's clip rect). pub fn paint(painter: &egui::Painter, rect: egui::Rect, params: ViewParams) { painter.add(egui_wgpu::Callback::new_paint_callback(rect, CanvasCallback { rect, params })); @@ -501,7 +642,35 @@ impl Perf { /// Straight-alpha f32 RGBA (sRGB-encoded) → premultiplied RGBA8, split across threads on native. pub fn premultiply_rgba8(px: &[[f32; 4]]) -> Vec { - fn convert(src: &[[f32; 4]], dst: &mut [u8]) { + let mut out = Vec::new(); + texels_into(FORMAT, px, &mut out); + out +} + +/// Straight-alpha f32 RGBA (sRGB-encoded) → premultiplied RGBA16F (little-endian), split across +/// threads on native. Colour values above 1.0 are kept (32-bit documents); negatives and NaN +/// become 0. +pub fn premultiply_rgba16f(px: &[[f32; 4]]) -> Vec { + let mut out = Vec::new(); + texels_into(FORMAT_HIGH, px, &mut out); + out +} + +/// Bytes per texel of a canvas texture format. +fn texel_bytes(format: wgpu::TextureFormat) -> u64 { + if format == FORMAT_HIGH { 8 } else { 4 } +} + +/// Straight-alpha f32 RGBA → premultiplied texels of `format`. +fn texels(format: wgpu::TextureFormat, px: &[[f32; 4]]) -> Vec { + let mut out = Vec::new(); + texels_into(format, px, &mut out); + out +} + +/// [`texels`] into `out` (resized; reusing it across bands saves page-faulting a fresh buffer). +fn texels_into(format: wgpu::TextureFormat, px: &[[f32; 4]], out: &mut Vec) { + fn rgba8(src: &[[f32; 4]], dst: &mut [u8]) { for (o, p) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { let a = p[3].clamp(0.0, 1.0); o[0] = (p[0].clamp(0.0, 1.0) * a * 255.0 + 0.5) as u8; @@ -510,20 +679,90 @@ pub fn premultiply_rgba8(px: &[[f32; 4]]) -> Vec { o[3] = (a * 255.0 + 0.5) as u8; } } - let mut out = vec![0u8; px.len() * 4]; + fn rgba16f(src: &[[f32; 4]], dst: &mut [u8]) { + for (o, p) in dst.as_chunks_mut::<8>().0.iter_mut().zip(src) { + let a = if p[3].is_nan() { 0.0 } else { p[3].clamp(0.0, 1.0) }; + let [r, g, b, a] = [p[0].max(0.0) * a, p[1].max(0.0) * a, p[2].max(0.0) * a, a].map(|v| f32_to_f16(v).to_le_bytes()); + *o = [r[0], r[1], g[0], g[1], b[0], b[1], a[0], a[1]]; + } + } + let bpp = texel_bytes(format) as usize; + let convert = if format == FORMAT_HIGH { rgba16f } else { rgba8 }; + out.clear(); + out.resize(px.len() * bpp, 0); #[cfg(not(target_arch = "wasm32"))] if px.len() > 1 << 18 { let threads = std::thread::available_parallelism().map_or(4, |n| n.get()).min(16); let chunk = px.len().div_ceil(threads); std::thread::scope(|s| { - for (src, dst) in px.chunks(chunk).zip(out.chunks_mut(chunk * 4)) { + for (src, dst) in px.chunks(chunk).zip(out.chunks_mut(chunk * bpp)) { s.spawn(move || convert(src, dst)); } }); - return out; + return; } - convert(px, &mut out); - out + convert(px, out); +} + +/// IEEE 754 binary32 → binary16, round to nearest even; overflow saturates to the largest +/// finite half (65504) so HDR values never become infinite, NaN becomes 0. Branch-light: the +/// rounding is done with integer adds on the f32 bits (and an f32 add for subnormals). +fn f32_to_f16(v: f32) -> u16 { + // 2^-14: the smallest normal half; below it the result is subnormal (or zero). + const MIN_NORMAL: u32 = 113 << 23; + // 2^16: halves overflow from here (65520 and up round to infinity; saturate instead). + const OVERFLOW: u32 = 143 << 23; + // Adding 0.5 (as f32) shifts a subnormal half's bits to the bottom of the mantissa. + const DENORM_MAGIC: u32 = 126 << 23; + let bits = v.to_bits(); + let sign = ((bits >> 16) & 0x8000) as u16; + let a = bits & 0x7fff_ffff; + if a > 0x7f80_0000 { + return 0; + } + let h = if a >= OVERFLOW { + 0x7bff + } else if a < MIN_NORMAL { + (f32::from_bits(a) + f32::from_bits(DENORM_MAGIC)).to_bits().wrapping_sub(DENORM_MAGIC) as u16 + } else { + // Rebias the exponent and round the 13 dropped mantissa bits to nearest even. + let odd = (a >> 13) & 1; + (((a.wrapping_add(0xc800_0fff).wrapping_add(odd)) >> 13).min(0x7bff)) as u16 + }; + sign | h +} + +/// IEEE 754 binary16 → binary32. +#[cfg(not(target_arch = "wasm32"))] +fn f16_to_f32(h: u16) -> f32 { + let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 }; + let exp = i32::from((h >> 10) & 0x1f); + let man = f32::from(h & 0x3ff); + sign * match exp { + 0 => man * 2f32.powi(-24), + 0x1f => { + if man == 0.0 { + f32::INFINITY + } else { + f32::NAN + } + } + e => (1.0 + man / 1024.0) * 2f32.powi(e - 15), + } +} + +/// One stored texel of `format` as f32 RGBA. +#[cfg(not(target_arch = "wasm32"))] +fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] { + let mut o = [0.0f32; 4]; + for (i, v) in o.iter_mut().enumerate() { + *v = if format == FORMAT_HIGH { + f16_to_f32(u16::from_le_bytes([b.get(i * 2).copied().unwrap_or(0), b.get(i * 2 + 1).copied().unwrap_or(0)])) + } else { + f32::from(b.get(i).copied().unwrap_or(0)) / 255.0 + }; + } + o } // --------------------------------------------------------------------------------------------- @@ -537,6 +776,9 @@ struct Resources { sampler: wgpu::Sampler, mip_bgl: wgpu::BindGroupLayout, mip_pipeline: wgpu::RenderPipeline, + /// `mip_pipeline` and `encode_pipeline` for `Rgba16Float` tiles (when the adapter supports them). + mip_pipeline_high: Option, + encode_pipeline_high: Option, mip_sampler: wgpu::Sampler, docs: HashMap, views: HashMap, @@ -613,6 +855,7 @@ struct ViewGpu { struct DocTextures { size: [u32; 2], + format: wgpu::TextureFormat, tiles: Vec, } @@ -686,7 +929,7 @@ fn pipeline( } impl Resources { - fn new(device: &wgpu::Device, queue: &wgpu::Queue, target: wgpu::TextureFormat) -> Self { + fn new(device: &wgpu::Device, queue: &wgpu::Queue, target: wgpu::TextureFormat, high: bool) -> Self { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_canvas"), source: wgpu::ShaderSource::Wgsl(CANVAS_WGSL.into()) }); let vis = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT; @@ -748,6 +991,7 @@ impl Resources { let mip_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_mip"), bind_group_layouts: &[Some(&mip_bgl)], immediate_size: 0 }); let mip_pipeline = pipeline(device, "pc_mip", &mip_layout, &mip_module, ("vs", "fs"), FORMAT, None); + let mip_pipeline_high = high.then(|| pipeline(device, "pc_mip_16f", &mip_layout, &mip_module, ("vs", "fs"), FORMAT_HIGH, None)); let mip_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("pc_mip"), mag_filter: wgpu::FilterMode::Linear, @@ -777,6 +1021,7 @@ impl Resources { immediate_size: 0, }); let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None); + let encode_pipeline_high = high.then(|| pipeline(device, "pc_encode_16f", &encode_layout, &encode_module, ("vs", "fs"), FORMAT_HIGH, None)); let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes()); Self { lut_bgl, @@ -790,6 +1035,8 @@ impl Resources { sampler, mip_bgl, mip_pipeline, + mip_pipeline_high, + encode_pipeline_high, mip_sampler, docs: HashMap::new(), views: HashMap::new(), @@ -800,6 +1047,16 @@ impl Resources { encode_pipeline, } } + + /// The pipeline encoding compositor output into a `format` tile. + fn encode_pipeline(&self, format: wgpu::TextureFormat) -> Option<&wgpu::RenderPipeline> { + if format == FORMAT_HIGH { self.encode_pipeline_high.as_ref() } else { Some(&self.encode_pipeline) } + } + + /// The mip-chain pipeline for `format` tiles. + fn mip_pipeline(&self, format: wgpu::TextureFormat) -> Option<&wgpu::RenderPipeline> { + if format == FORMAT_HIGH { self.mip_pipeline_high.as_ref() } else { Some(&self.mip_pipeline) } + } } fn mip_count(w: u32, h: u32) -> u32 { @@ -807,7 +1064,7 @@ fn mip_count(w: u32, h: u32) -> u32 { } impl DocTextures { - fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32) -> Self { + fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self { let mut tiles = Vec::new(); for ty in (0..size[1]).step_by(tile as usize) { for tx in (0..size[0]).step_by(tile as usize) { @@ -819,8 +1076,11 @@ impl DocTextures { mip_level_count: levels_n, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: FORMAT, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let levels = (0..levels_n) @@ -843,12 +1103,13 @@ impl DocTextures { tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); } } - Self { size, tiles } + Self { size, format, tiles } } - /// Write `size` pixels at document `origin` from `rgba` (row stride `stride` pixels) into every - /// overlapping tile's level 0. - fn write(&self, queue: &wgpu::Queue, origin: [u32; 2], size: [u32; 2], stride: u32, rgba: &[u8]) { + /// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride + /// `stride` pixels) into every overlapping tile's level 0. + fn write(&self, queue: &wgpu::Queue, origin: [u32; 2], size: [u32; 2], stride: u32, texels: &[u8]) { + let bpp = texel_bytes(self.format); let (x0, y0, x1, y1) = (origin[0], origin[1], origin[0] + size[0], origin[1] + size[1]); for t in &self.tiles { let [tx, ty, tw, th] = t.rect; @@ -856,7 +1117,7 @@ impl DocTextures { if ix0 >= ix1 || iy0 >= iy1 { continue; } - let offset = ((iy0 - y0) as u64 * stride as u64 + (ix0 - x0) as u64) * 4; + let offset = ((iy0 - y0) as u64 * stride as u64 + (ix0 - x0) as u64) * bpp; queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &t.texture, @@ -864,8 +1125,8 @@ impl DocTextures { origin: wgpu::Origin3d { x: ix0 - tx, y: iy0 - ty, z: 0 }, aspect: wgpu::TextureAspect::All, }, - rgba, - wgpu::TexelCopyBufferLayout { offset, bytes_per_row: Some(stride * 4), rows_per_image: Some(iy1 - iy0) }, + texels, + wgpu::TexelCopyBufferLayout { offset, bytes_per_row: Some(stride * bpp as u32), rows_per_image: Some(iy1 - iy0) }, wgpu::Extent3d { width: ix1 - ix0, height: iy1 - iy0, depth_or_array_layers: 1 }, ); } @@ -873,6 +1134,7 @@ impl DocTextures { /// Rebuild the mip chain inside document rect `r` (x0, y0, x1, y1) with 2×2 box filtering. fn regenerate_mips(&self, device: &wgpu::Device, queue: &wgpu::Queue, res: &Resources, r: [u32; 4]) { + let Some(mip_pipeline) = res.mip_pipeline(self.format) else { return }; let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_mips") }); for t in &self.tiles { let [tx, ty, tw, th] = t.rect; @@ -907,7 +1169,7 @@ impl DocTextures { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&res.mip_pipeline); + pass.set_pipeline(mip_pipeline); pass.set_bind_group(0, &bg, &[]); pass.set_scissor_rect(x0, y0, x1 - x0, y1 - y0); pass.draw(0..3, 0..1); @@ -954,6 +1216,11 @@ impl CanvasCallback { let grid = if p.pixel_grid && p.zoom >= 8.0 { 0.16 } else { 0.0 }; let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 }; let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color); + // 32-bit preview: linear-light gain 2^exposure (0 = off) and 1 / gamma. + let (gain, inv_gamma) = match p.hdr { + Some([e, gm]) if e.is_finite() && gm.is_finite() && gm > 0.0 => (2f32.powf(e.clamp(-20.0, 20.0)), 1.0 / gm.max(0.01)), + _ => (0.0, 1.0), + }; [ screen[0] as f32, screen[1] as f32, @@ -978,11 +1245,11 @@ impl CanvasCallback { d[0], d[1], d[2], - 0.0, + gain, g[0], g[1], g[2], - 0.0, + inv_gamma, ] } } @@ -1068,8 +1335,8 @@ struct View { c: vec4, // filter mode, lod, grid alpha, checker square (device px) d: vec4, // checker anchor x, y (device px), display (0 none, 1 LUT, 2 LUT + gamut), output linear e: vec4, // checker light rgb, gamut warning opacity - f: vec4, // checker dark rgb - g: vec4, // gamut warning rgb + f: vec4, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off) + g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma }; struct Tile { r: vec4 }; // x, y, w, h in doc px @@ -1100,6 +1367,11 @@ fn srgb_to_linear(c: vec3) -> vec3 { return select(hi, lo, c <= vec3(0.04045)); } +fn linear_to_srgb(c: vec3) -> vec3 { + let hi = 1.055 * pow(max(c, vec3(1e-12)), vec3(1.0 / 2.4)) - 0.055; + return select(hi, c * 12.92, c <= vec3(0.0031308)); +} + // --- drop shadow --------------------------------------------------------------------------- fn erf2(x: vec2) -> vec2 { @@ -1177,6 +1449,14 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { let f = clamp((tt - i - 0.5) * scale + 0.5, vec2(0.0), vec2(1.0)); col = textureSampleLevel(tex, samp, (i + 0.5 + f) / size, 0.0); } + if (view.f.w > 0.0 && col.a > 0.0) { + // View › 32-bit Preview Options: exposure and gamma in linear light on the sRGB-encoded + // texture value, before the display LUT. A float canvas keeps values above 1.0, so they + // are brought into range by the exposure instead of clipped. + let lin = srgb_to_linear(max(col.rgb / col.a, vec3(0.0))) * view.f.w; + let v = linear_to_srgb(min(pow(max(lin, vec3(1e-12)), vec3(view.g.w)), vec3(1.0))); + col = vec4(v * col.a, col.a); + } if (view.d.z > 0.5 && col.a > 0.0) { // Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags // out-of-gamut colours). @@ -1204,7 +1484,7 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { const ENCODE_WGSL: &str = r#" @group(0) @binding(0) var acc: texture_2d; -@group(0) @binding(1) var offset: vec4; +@group(0) @binding(1) var offset: vec4; // x, y, encode sRGB, keep values above 1.0 @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4 { @@ -1220,7 +1500,10 @@ fn srgb_encode(c: vec3) -> vec3 { fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { let c = textureLoad(acc, vec2(floor(pos.xy)) - offset.xy, 0); let a = clamp(c.a, 0.0, 1.0); - var rgb = clamp(c.rgb, vec3(0.0), vec3(1.0)); + var rgb = max(c.rgb, vec3(0.0)); + if (offset.w == 0) { + rgb = min(rgb, vec3(1.0)); + } if (offset.z != 0) { // Linear composite: store its sRGB encoding (8-bit precision where it matters). rgb = srgb_encode(rgb); @@ -1268,6 +1551,28 @@ mod tests { assert_eq!(premultiply_rgba8(&[[1.0, 0.5, 0.0, 0.5]]), vec![128, 64, 0, 128]); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn half_floats() { + // Every finite half survives the round trip; ties round to even; overflow saturates. + for h in 0..=u16::MAX { + if h & 0x7c00 != 0x7c00 && h != 0x8000 { + assert_eq!(f32_to_f16(f16_to_f32(h)), h, "{h:#06x}"); + } + } + assert_eq!(f32_to_f16(1.0), 0x3c00); + assert_eq!(f32_to_f16(1.0 + 2f32.powi(-11)), 0x3c00); + assert_eq!(f32_to_f16(1.0 + 3.0 * 2f32.powi(-11)), 0x3c02); + assert_eq!(f32_to_f16(2f32.powi(-24)), 1); + assert_eq!(f32_to_f16(2f32.powi(-26)), 0); + assert_eq!(f32_to_f16(65504.0), 0x7bff); + assert_eq!(f32_to_f16(1e9), 0x7bff); + assert_eq!(f32_to_f16(f32::INFINITY), 0x7bff); + assert_eq!(f32_to_f16(f32::NAN), 0); + assert_eq!(f32_to_f16(-2.0), 0xc000); + assert_eq!(premultiply_rgba16f(&[[2.0, -1.0, f32::NAN, 0.5]]), [0x00, 0x3c, 0, 0, 0, 0, 0x00, 0x38]); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn shaders_validate() { diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/canvas_16f.rs new file mode 100644 index 00000000..0a716b5b --- /dev/null +++ b/crates/ui-egui/tests/canvas_16f.rs @@ -0,0 +1,250 @@ +//! 16/32-bit documents get an `Rgba16Float` canvas texture (#46): smooth gradients keep more than +//! 256 levels, 32-bit values above 1.0 survive for View › 32-bit Preview Options, and 8-bit +//! documents keep their `Rgba8Unorm` texture byte for byte. Skips when there is no GPU adapter or +//! it can't render to `Rgba16Float`. + +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use eframe::egui_wgpu::RenderState; +use eframe::wgpu::TextureFormat; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::gpu_canvas::{self, GpuCanvas}; +use serde_json::json; + +/// A headless GPU canvas on a device created like the app's, if the adapter has a float canvas. +fn canvas() -> Option<(GpuCanvas, RenderState)> { + let rs = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(gpu_canvas::wgpu_setup(), Default::default()))).ok(); + let Some(rs) = rs else { + eprintln!("skipping: no GPU adapter"); + return None; + }; + if !gpu_canvas::supports_f16_canvas(&rs.adapter) || std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() == Ok("0") { + eprintln!("skipping: adapter can't render/filter Rgba16Float"); + return None; + } + Some((GpuCanvas::new(&rs), rs)) +} + +/// A one-layer `w`×`h` RGB document of `depth` whose columns ramp from `lo` to `hi` (gray). +fn gradient(depth: SampleType, w: u32, h: u32, lo: f32, hi: f32) -> Document { + let mut d = Document::new("gradient", Size::new(w, h), ColorMode::Rgb, depth); + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + for x in 0..w { + let v = lo + (hi - lo) * x as f32 / (w - 1) as f32; + s.fill_rect(Rect::new(x as i32, 0, x as i32 + 1, h as i32), &[v, v, v, 1.0]); + } + d.layers.push(Layer::new("ramp", LayerContent::Raster(s))); + d +} + +/// Distinct red values along row `y` of read-back texels. +fn levels(texels: &[[f32; 4]], w: u32, y: u32) -> usize { + let row = &texels[(y * w) as usize..((y + 1) * w) as usize]; + row.iter().map(|p| p[0].to_bits()).collect::>().len() +} + +/// The stored texels of a document's canvas texture against its CPU composite (premultiplied). +fn max_error(doc: &Document, texels: &[[f32; 4]]) -> f32 { + let buf = photocraft_compose::render(doc, doc.bounds()); + buf.px.iter().zip(texels).map(|(c, t)| (0..4).map(|i| ((if i < 3 { c[i] * c[3] } else { c[3] }) - t[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) +} + +#[test] +fn sixteen_bit_gradient_keeps_more_than_256_levels() { + let Some((g, _rs)) = canvas() else { return }; + let doc = gradient(SampleType::U16, 4096, 4, 0.0, 1.0); + let key = doc.id.0; + // GPU compositor (or the CPU fallback where it doesn't apply). + let r = g.refresh(key, &doc, None, None); + let (format, size, texels) = g.read_texels(key).expect("read back"); + assert_eq!(format, TextureFormat::Rgba16Float, "refresh path {}", r.kind); + assert_eq!(size, [4096, 4]); + let n = levels(&texels, 4096, 0); + eprintln!("{} refresh: {n} levels", r.kind); + assert!(n > 256, "{n} levels: banded"); + assert!(max_error(&doc, &texels) < 2e-3, "texels differ from the composite"); + // The CPU compositor's banded upload. + let doc2 = gradient(SampleType::U16, 4096, 4, 0.0, 1.0); + g.upload_composite(doc2.id.0, &doc2, None); + let (format, _, texels) = g.read_texels(doc2.id.0).expect("read back"); + assert_eq!(format, TextureFormat::Rgba16Float); + let n = levels(&texels, 4096, 3); + assert!(n > 256, "CPU path: {n} levels"); + assert!(max_error(&doc2, &texels) < 2e-3, "CPU path texels differ from the composite"); + // Memory: 8 bytes per texel plus mips. + let (_, bytes) = g.texture_info(doc2.id.0).expect("info"); + assert!((4096 * 4 * 8..4096 * 4 * 8 * 3 / 2).contains(&bytes), "{bytes} bytes"); +} + +#[test] +fn damage_rect_uploads_into_the_float_texture() { + let Some((g, _rs)) = canvas() else { return }; + let mut doc = gradient(SampleType::U16, 512, 64, 0.0, 1.0); + let key = doc.id.0; + g.upload_composite(key, &doc, None); + // Repaint a patch and upload only it, as the CPU path does after a brush stroke. + if let LayerContent::Raster(s) = &mut doc.layers[0].content { + s.fill_rect(Rect::new(100, 10, 140, 30), &[0.123_45, 0.5, 0.987_65, 1.0]); + } + let r = g.refresh(key, &doc, Some(Rect::new(100, 10, 140, 30)), None); + assert!(r.kind.ends_with("rect"), "{}", r.kind); + let (_, _, texels) = g.read_texels(key).expect("read back"); + let p = texels[20 * 512 + 120]; + assert!((p[0] - 0.123_45).abs() < 1e-3 && (p[2] - 0.987_65).abs() < 1e-3, "{p:?}"); + assert!(max_error(&doc, &texels) < 2e-3); +} + +#[test] +fn eight_bit_documents_keep_their_rgba8_texture() { + let Some((g, _rs)) = canvas() else { return }; + let doc = gradient(SampleType::U8, 1024, 4, 0.0, 1.0); + // CPU path: exactly the premultiplied RGBA8 of the composite, as before. + g.upload_composite(doc.id.0, &doc, None); + let (format, _, texels) = g.read_texels(doc.id.0).expect("read back"); + assert_eq!(format, TextureFormat::Rgba8Unorm); + let want = gpu_canvas::premultiply_rgba8(&photocraft_compose::render(&doc, doc.bounds()).px); + let got: Vec = texels.iter().flat_map(|p| p.map(|v| (v * 255.0).round() as u8)).collect(); + assert!(got == want, "8-bit texels changed"); + assert!(levels(&texels, 1024, 0) <= 256); + // GPU path: still RGBA8. + let doc2 = gradient(SampleType::U8, 1024, 4, 0.0, 1.0); + g.refresh(doc2.id.0, &doc2, None, None); + assert_eq!(g.texture_info(doc2.id.0).map(|i| i.0), Some(TextureFormat::Rgba8Unorm)); + // Converting to 16 bits switches the texture to float. + let mut doc3 = doc2.clone(); + doc3.depth = SampleType::U16; + if let LayerContent::Raster(s) = &mut doc3.layers[0].content { + *s = s.convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true)); + } + g.refresh(doc3.id.0, &doc3, Some(Rect::new(0, 0, 4, 4)), None); + assert_eq!(g.texture_info(doc3.id.0).map(|i| i.0), Some(TextureFormat::Rgba16Float)); +} + +#[test] +fn format_follows_depth_and_budget() { + let Some((g, _rs)) = canvas() else { return }; + assert_eq!(g.format_for(SampleType::U8, [4000, 4000]), TextureFormat::Rgba8Unorm); + assert_eq!(g.format_for(SampleType::U16, [7360, 4912]), TextureFormat::Rgba16Float); + assert_eq!(g.format_for(SampleType::F32, [7360, 4912]), TextureFormat::Rgba16Float); + // Over the budget (a 14000² 16-bit document): 8-bit, so it stays on the GPU. + if std::env::var_os("PHOTOCRAFT_CANVAS_F16").is_none() { + assert_eq!(g.format_for(SampleType::U16, [14000, 14000]), TextureFormat::Rgba8Unorm); + } +} + +#[test] +fn thirty_two_bit_values_above_one_are_kept() { + let Some((g, _rs)) = canvas() else { return }; + let doc = gradient(SampleType::F32, 256, 2, 0.0, 4.0); + g.refresh(doc.id.0, &doc, None, None); + let (format, _, texels) = g.read_texels(doc.id.0).expect("read back"); + assert_eq!(format, TextureFormat::Rgba16Float); + let last = texels[255][0]; + assert!((last - 4.0).abs() < 0.01, "{last}"); + let doc2 = gradient(SampleType::F32, 256, 2, 0.0, 4.0); + g.upload_composite(doc2.id.0, &doc2, None); + let (_, _, texels) = g.read_texels(doc2.id.0).expect("read back"); + assert!((texels[255][0] - 4.0).abs() < 0.01, "CPU path {}", texels[255][0]); +} + +/// A rendered screen: width and RGBA8 rows. +struct Screen { + w: u32, + h: u32, + px: Vec, +} + +impl Screen { + fn green(&self, x: u32, y: u32) -> u8 { + self.px[((y * self.w + x) * 4 + 1) as usize] + } +} + +/// Renders the app with `doc` open after `setup` commands (saving the PNG as `name` in the temp +/// dir), or None when the GPU can't be used. +fn screen(doc: Document, setup: &[(&str, serde_json::Value)], name: &str) -> Option { + let ok = Arc::new(AtomicBool::new(false)); + let ok2 = ok.clone(); + let setup: Vec<(String, serde_json::Value)> = setup.iter().map(|(a, b)| (a.to_string(), b.clone())).collect(); + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + ok2.store(gpu_canvas::supports_f16_canvas(&rs.adapter) && std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() != Ok("0"), Ordering::SeqCst); + app.set_wgpu(rs.clone()); + } + app.session.open_document(doc, None); + for (id, p) in setup { + app.run(&id, p).expect("setup command"); + } + app + }) + })); + let Ok(mut harness) = built else { + eprintln!("skipping: no GPU adapter"); + return None; + }; + if !ok.load(Ordering::SeqCst) { + eprintln!("skipping: adapter can't render/filter Rgba16Float"); + return None; + } + harness.run_steps(6); + let img = harness.render().expect("render"); + let out = std::env::temp_dir().join(name); + let _ = img.save(&out); + eprintln!("screen: {}", out.display()); + Some(Screen { w: img.width(), h: img.height(), px: img.into_raw() }) +} + +/// Distinct gray levels along a screen row inside `x0..x1`, and the largest step between +/// neighbouring pixels. +fn screen_levels(img: &Screen, y: u32, x0: u32, x1: u32) -> (usize, i32) { + let row: Vec = (x0..x1).map(|x| img.green(x, y)).collect(); + let steps = row.windows(2).map(|w| (w[1] as i32 - w[0] as i32).abs()).max().unwrap_or(0); + (row.iter().collect::>().len(), steps) +} + +#[test] +fn sixteen_bit_gradient_on_screen() { + // A 16-bit ramp drawn by the app's canvas: smooth, monotonic, no visible steps. + let doc = gradient(SampleType::U16, 2048, 1024, 0.0, 1.0); + let Some(img) = screen(doc, &[], "photocraft-canvas-16f-gradient.png") else { return }; + let (n, step) = screen_levels(&img, img.h / 2, 150, 600); + assert!(n > 100 && step <= 3, "{n} levels, max step {step}"); +} + +#[test] +fn thirty_two_bit_preview_exposes_values_above_one() { + // A dark 32-bit ramp brightened +4 stops by View › 32-bit Preview Options: with an 8-bit + // canvas texture its 0..1/16 range has 16 codes (banding); the float texture keeps it smooth. + let doc = gradient(SampleType::F32, 2048, 1024, 0.0, 1.0 / 16.0); + let Some(img) = screen(doc, &[("view.thirtyTwoBitPreviewOptions", json!({"exposure": 4.0, "gamma": 1.0}))], "photocraft-canvas-16f-hdr-ramp.png") else { + return; + }; + let (n, _) = screen_levels(&img, img.h / 2, 150, 600); + assert!(n > 40, "{n} levels: banded"); + // Values above 1.0 are brought into range by a negative exposure instead of clipped: 2.0 + // at −3 stops shows as sRGB(lin(2.0) / 8) rather than sRGB(lin(1.0) / 8). + let mut flat = Document::new("hdr", Size::new(512, 512), ColorMode::Rgb, SampleType::F32); + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + s.fill_rect(Rect::new(0, 0, 512, 512), &[2.0, 2.0, 2.0, 1.0]); + flat.layers.push(Layer::new("bright", LayerContent::Raster(s))); + let Some(img) = screen(flat, &[("view.thirtyTwoBitPreviewOptions", json!({"exposure": -3.0, "gamma": 1.0}))], "photocraft-canvas-16f-hdr-flat.png") else { + return; + }; + let got = img.green(img.w * 2 / 5, img.h / 2) as f32; + let dec = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let enc = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + let want = enc(dec(2.0) / 8.0) * 255.0; + let clipped = enc(dec(1.0) / 8.0) * 255.0; + eprintln!("shown {got}, want {want:.1} (clipped would be {clipped:.1})"); + assert!((got - want).abs() <= 3.0, "shown {got}, want {want:.1} (clipped {clipped:.1})"); +}