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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 23:30:53 -04:00 committed by GitHub
parent 2149f7f646
commit d8d648cb72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 680 additions and 72 deletions

View File

@ -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<Lut3d> {
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<Lut3d> {
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<crate::proof_sim::HdrPreview> {
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<Option<Vec<u8>>> {
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<Option<Vec<u8>>> {
self.canvas_lut_with(doc, size, false)
}
fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool) -> Result<Option<Vec<u8>>> {
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;

View File

@ -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)

View File

@ -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<Option<Lut3d>> {
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<Option<Lut3d>> {
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));

View File

@ -13,6 +13,8 @@
//!
//! The source JPEG is generated once and cached (`--cache <dir>`, 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() {

View File

@ -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 {

View File

@ -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::<u32>().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::<Resources>()?.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::<u64>())
.sum();
Some((d.format, bytes))
}
/// Set the checkerboard and gamut warning colours.
@ -94,56 +161,73 @@ impl GpuCanvas {
r.callback_resources.get::<Resources>().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::<Resources>() 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::<Resources>() 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<wgpu::TextureFormat> {
let r = self.rs.renderer.read();
r.callback_resources.get::<Resources>().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::<Vec<u8>>(),
@ -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::<Resources>() 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::<Resources>().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::<Resources>()?.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<u8> {
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<u8> {
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<u8> {
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<u8>) {
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<u8> {
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<wgpu::RenderPipeline>,
encode_pipeline_high: Option<wgpu::RenderPipeline>,
mip_sampler: wgpu::Sampler,
docs: HashMap<u64, DocTextures>,
views: HashMap<u64, ViewGpu>,
@ -613,6 +855,7 @@ struct ViewGpu {
struct DocTextures {
size: [u32; 2],
format: wgpu::TextureFormat,
tiles: Vec<Tile>,
}
@ -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<f32>, // filter mode, lod, grid alpha, checker square (device px)
d: vec4<f32>, // checker anchor x, y (device px), display (0 none, 1 LUT, 2 LUT + gamut), output linear
e: vec4<f32>, // checker light rgb, gamut warning opacity
f: vec4<f32>, // checker dark rgb
g: vec4<f32>, // gamut warning rgb
f: vec4<f32>, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off)
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
};
struct Tile { r: vec4<f32> }; // x, y, w, h in doc px
@ -1100,6 +1367,11 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return select(hi, lo, c <= vec3(0.04045));
}
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
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<f32>) -> vec2<f32> {
@ -1177,6 +1449,14 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
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<f32> {
const ENCODE_WGSL: &str = r#"
@group(0) @binding(0) var acc: texture_2d<f32>;
@group(0) @binding(1) var<uniform> offset: vec4<i32>;
@group(0) @binding(1) var<uniform> offset: vec4<i32>; // x, y, encode sRGB, keep values above 1.0
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
@ -1220,7 +1500,10 @@ fn srgb_encode(c: vec3<f32>) -> vec3<f32> {
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = textureLoad(acc, vec2<i32>(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() {

View File

@ -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::<HashSet<_>>().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<u8> = 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<u8>,
}
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<Screen> {
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<u8> = (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::<HashSet<_>>().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})");
}