* 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:
parent
2149f7f646
commit
d8d648cb72
|
|
@ -321,25 +321,48 @@ impl ColorState {
|
||||||
/// The display transform as an `size³` RGBA 3D LUT (upload with
|
/// The display transform as an `size³` RGBA 3D LUT (upload with
|
||||||
/// [`Lut3d::to_rgba16f_bytes`] and apply in the canvas shader).
|
/// [`Lut3d::to_rgba16f_bytes`] and apply in the canvas shader).
|
||||||
pub fn display_lut(&self, doc: &Document, size: usize) -> Result<Lut3d> {
|
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);
|
return Ok(lut);
|
||||||
}
|
}
|
||||||
let t = self.display_transform(doc)?;
|
let t = self.display_transform(doc)?;
|
||||||
Ok(Lut3d::from_transform(&t, size))
|
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
|
/// 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,
|
/// 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)
|
/// 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
|
/// 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).
|
/// of the proof gamut (only with Gamut Warning on).
|
||||||
pub fn canvas_lut(&self, doc: &Document, size: usize) -> Result<Option<Vec<u8>>> {
|
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 pv = self.proof(doc.id);
|
||||||
let display = self.canvas_display(doc)?;
|
let display = self.canvas_display(doc)?;
|
||||||
if !pv.enabled && !pv.gamut_warning && !crate::proof_sim::hdr_active(self, doc) {
|
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.max(2)).to_rgba8()));
|
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 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 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;
|
let s = (size - 1) as f32;
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ impl CanvasDisplay {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GPU canvas: a CPU composite as the texture stores it (sRGB-encoded for linear
|
/// 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> {
|
pub fn texture_buffer<'a>(&self, buf: &'a Buffer) -> Cow<'a, Buffer> {
|
||||||
if !self.encode_srgb {
|
if !self.encode_srgb {
|
||||||
return Cow::Borrowed(buf);
|
return Cow::Borrowed(buf);
|
||||||
|
|
@ -74,7 +75,8 @@ impl CanvasDisplay {
|
||||||
let mut b = buf.clone();
|
let mut b = buf.clone();
|
||||||
for p in &mut b.px {
|
for p in &mut b.px {
|
||||||
for v in &mut p[..3] {
|
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)
|
Cow::Owned(b)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
/// 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>> {
|
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 pv = c.proof(doc.id);
|
||||||
let kind = if pv.enabled { pv.setup.kind } else { ProofKind::Profile };
|
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() {
|
if kind == ProofKind::Profile && hdr.is_none() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +429,9 @@ mod tests {
|
||||||
let q = sample(&lut, [1, 1, 1]);
|
let q = sample(&lut, [1, 1, 1]);
|
||||||
assert!((q[0] - 0.354).abs() < 0.02, "{q:?}");
|
assert!((q[0] - 0.354).abs() < 0.02, "{q:?}");
|
||||||
assert!(s.color.canvas_lut(&d, 5).unwrap().is_some());
|
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.
|
// Highlight Compression: identity mapping, so no LUT.
|
||||||
s.execute("view.thirtyTwoBitPreviewOptions", json!({"method": "highlightCompression"})).unwrap();
|
s.execute("view.thirtyTwoBitPreviewOptions", json!({"method": "highlightCompression"})).unwrap();
|
||||||
assert!(!hdr_active(&s.color, &d));
|
assert!(!hdr_active(&s.color, &d));
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
//!
|
//!
|
||||||
//! The source JPEG is generated once and cached (`--cache <dir>`, default the system temp dir).
|
//! 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.
|
//! `--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;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
|
@ -109,11 +111,21 @@ fn main() {
|
||||||
drop(bytes);
|
drop(bytes);
|
||||||
let mut s = Session::new();
|
let mut s = Session::new();
|
||||||
s.open_document(doc, Some("photo.jpg".into()));
|
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_open = ms(t);
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
let path = refresh(gpu, &s, true);
|
let path = refresh(gpu, &s, true);
|
||||||
println!("open (decode + document) {t_open:>9.0} ms");
|
println!("open (decode + document) {t_open:>9.0} ms");
|
||||||
println!("first refresh ({path:<8}) {:>9.0} ms", ms(t));
|
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();
|
let doc = || s.active().expect("doc").doc.clone();
|
||||||
match op.as_str() {
|
match op.as_str() {
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6
|
||||||
let buf = photocraft_compose::flatten(r);
|
let buf = photocraft_compose::flatten(r);
|
||||||
let t1 = crate::gpu_canvas::now_ms();
|
let t1 = crate::gpu_canvas::now_ms();
|
||||||
let (display, _) = canvas_display(app, &doc);
|
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.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 });
|
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 buf = photocraft_compose::flatten(&p);
|
||||||
let t1 = crate::gpu_canvas::now_ms();
|
let t1 = crate::gpu_canvas::now_ms();
|
||||||
let (display, _) = canvas_display(app, &doc);
|
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.perf.record("proxy", p.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1);
|
||||||
app.proxy_uploaded = Some((doc_id, hash));
|
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;
|
const DISPLAY_LUT: usize = 33;
|
||||||
|
|
||||||
/// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with
|
/// 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
|
/// `doc`'s colour management: document → monitor profile and View › Proof Colors / Gamut
|
||||||
/// and 32-bit preview. Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on
|
/// 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).
|
/// 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 {
|
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 };
|
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;
|
return mode;
|
||||||
}
|
}
|
||||||
let gamut = app.session.color.proof(doc.id).gamut_warning;
|
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)) => {
|
Ok(Some(bytes)) => {
|
||||||
gpu.set_display_lut(key, DISPLAY_LUT as u32, Some(&bytes));
|
gpu.set_display_lut(key, DISPLAY_LUT as u32, Some(&bytes));
|
||||||
if gamut { 2 } else { 1 }
|
if gamut { 2 } else { 1 }
|
||||||
|
|
@ -796,6 +796,11 @@ fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocra
|
||||||
mode
|
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).
|
/// 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 {
|
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();
|
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,
|
pixel_grid: false,
|
||||||
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
|
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
|
||||||
display: sync_display_lut(app, &ctx, &doc, key),
|
display: sync_display_lut(app, &ctx, &doc, key),
|
||||||
|
hdr: hdr_preview(app, &doc),
|
||||||
};
|
};
|
||||||
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
|
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
|
||||||
} else if !flip && ensure_gpu(app, idx) {
|
} 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,
|
pixel_grid,
|
||||||
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
|
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
|
||||||
display: sync_display_lut(app, &ctx, &doc, doc.id.0),
|
display: sync_display_lut(app, &ctx, &doc, doc.id.0),
|
||||||
|
hdr: hdr_preview(app, &doc),
|
||||||
};
|
};
|
||||||
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
|
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
//! GPU document canvas: an `egui_wgpu` paint callback that draws one document view with a custom
|
//! GPU document canvas: an `egui_wgpu` paint callback that draws one document view with a custom
|
||||||
//! WGSL shader.
|
//! WGSL shader.
|
||||||
//!
|
//!
|
||||||
//! The document's composite lives on the GPU as one or more mipmapped `Rgba8Unorm` textures
|
//! The document's composite lives on the GPU as one or more mipmapped textures (premultiplied
|
||||||
//! (premultiplied alpha, sRGB-encoded values, matching how the CPU path and egui blend). Uploads
|
//! alpha, sRGB-encoded values, matching how the CPU path and egui blend): `Rgba8Unorm` for 8-bit
|
||||||
//! are either full (new document, size change, re-render) or a damage rect (brush strokes); after
|
//! documents, `Rgba16Float` for 16- and 32-bit documents when the adapter can render to and
|
||||||
//! an upload only the affected region of each mip level is regenerated by a render-pass blit chain.
|
//! filter it (so smooth gradients don't band and 32-bit values above 1.0 survive for the 32-bit
|
||||||
//! Documents larger than the device's maximum texture size are split into tiles.
|
//! 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*:
|
//! Per frame the shader draws, in *device pixels*:
|
||||||
//! - a soft analytic drop shadow around the document on the pasteboard (erf-blurred box),
|
//! - 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.
|
/// Default tile side; documents up to this size (and the device limit) use a single texture.
|
||||||
pub const DEFAULT_TILE: u32 = 8192;
|
pub const DEFAULT_TILE: u32 = 8192;
|
||||||
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
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_UNIFORM_SIZE: u64 = 112;
|
||||||
const VIEW_FLOATS: usize = 28;
|
const VIEW_FLOATS: usize = 28;
|
||||||
const TILE_UNIFORM_SIZE: u64 = 16;
|
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,
|
/// 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`]).
|
/// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]).
|
||||||
pub display: u8,
|
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.
|
/// Uploads documents to the GPU and paints them. Cheap to clone.
|
||||||
|
|
@ -61,6 +87,15 @@ pub struct ViewParams {
|
||||||
pub struct GpuCanvas {
|
pub struct GpuCanvas {
|
||||||
rs: RenderState,
|
rs: RenderState,
|
||||||
tile: u32,
|
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 {
|
impl GpuCanvas {
|
||||||
|
|
@ -74,10 +109,42 @@ impl GpuCanvas {
|
||||||
let max = rs.device.limits().max_texture_dimension_2d;
|
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 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 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);
|
rs.renderer.write().callback_resources.insert(res);
|
||||||
log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}", rs.target_format);
|
log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format);
|
||||||
Self { rs: rs.clone(), tile }
|
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.
|
/// 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)
|
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]) {
|
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");
|
self.upload_full_as(doc, size, FORMAT, rgba);
|
||||||
if size[0] == 0 || size[1] == 0 {
|
}
|
||||||
|
|
||||||
|
/// 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;
|
return;
|
||||||
}
|
}
|
||||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||||
let mut renderer = self.rs.renderer.write();
|
let mut renderer = self.rs.renderer.write();
|
||||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||||
if res.docs.get(&doc).is_none_or(|d| d.size != size) {
|
if res.docs.get(&doc).is_none_or(|d| d.size != size || d.format != format) {
|
||||||
let tex = DocTextures::new(device, res, size, self.tile);
|
let tex = DocTextures::new(device, res, size, self.tile, format);
|
||||||
res.docs.insert(doc, tex);
|
res.docs.insert(doc, tex);
|
||||||
}
|
}
|
||||||
let d = &res.docs[&doc];
|
let Some(d) = res.docs.get(&doc) else { return };
|
||||||
d.write(queue, [0, 0], size, size[0], rgba);
|
d.write(queue, [0, 0], size, size[0], texels);
|
||||||
d.regenerate_mips(device, queue, res, [0, 0, size[0], size[1]]);
|
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
|
/// Replace a rectangle `origin..origin+size` of an uploaded document with premultiplied texels
|
||||||
/// pixels (`size[0] * size[1] * 4` bytes). Returns false if the document isn't uploaded.
|
/// in its texture format (`size[0] * size[1]` texels: RGBA8 or RGBA16F). Returns false if the
|
||||||
pub fn upload_rect(&self, doc: u64, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) -> bool {
|
/// document isn't uploaded, the rectangle doesn't fit or the buffer has the wrong size.
|
||||||
assert_eq!(rgba.len(), size[0] as usize * size[1] as usize * 4, "upload_rect: buffer size mismatch");
|
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 (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||||
let mut renderer = self.rs.renderer.write();
|
let mut renderer = self.rs.renderer.write();
|
||||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return false };
|
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return false };
|
||||||
let Some(d) = res.docs.get(&doc) 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;
|
return false;
|
||||||
}
|
}
|
||||||
if size[0] == 0 || size[1] == 0 {
|
if size[0] == 0 || size[1] == 0 {
|
||||||
return true;
|
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]]);
|
d.regenerate_mips(device, queue, res, [origin[0], origin[1], origin[0] + size[0], origin[1] + size[1]]);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a CPU composite covering the whole document.
|
/// The texture format of uploaded document `doc`.
|
||||||
pub fn upload_buffer_full(&self, doc: u64, buf: &photocraft_compose::Buffer) {
|
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()];
|
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`).
|
/// 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 {
|
pub fn upload_buffer_rect(&self, doc: u64, buf: &photocraft_compose::Buffer) -> bool {
|
||||||
let r = buf.rect;
|
let r = buf.rect;
|
||||||
|
let Some(format) = self.format_of(doc) else { return false };
|
||||||
if r.x0 < 0 || r.y0 < 0 {
|
if r.x0 < 0 || r.y0 < 0 {
|
||||||
return false;
|
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
|
/// 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 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()) };
|
let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) };
|
||||||
if let Err(e) = comp.supports(doc) {
|
if let Err(e) = comp.supports(doc) {
|
||||||
res.compositor = Some(comp);
|
res.compositor = Some(comp);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
if fresh {
|
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);
|
res.docs.insert(key, tex);
|
||||||
}
|
}
|
||||||
let d = &res.docs[&key];
|
let (Some(pipe), Some(d)) = (res.encode_pipeline(format), res.docs.get(&key)) else {
|
||||||
let (bgl, pipe) = (&res.encode_bgl, &res.encode_pipeline);
|
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| {
|
let result = comp.render(device, queue, doc, region, |enc, out| {
|
||||||
for t in &d.tiles {
|
for t in &d.tiles {
|
||||||
let [tx, ty, tw, th] = t.rect.map(|v| v as i32);
|
let [tx, ty, tw, th] = t.rect.map(|v| v as i32);
|
||||||
|
|
@ -202,7 +292,7 @@ impl GpuCanvas {
|
||||||
if r.is_empty() {
|
if r.is_empty() {
|
||||||
continue;
|
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 {
|
let ubuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("pc_encode"),
|
label: Some("pc_encode"),
|
||||||
contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(),
|
contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(),
|
||||||
|
|
@ -262,7 +352,8 @@ impl GpuCanvas {
|
||||||
) -> Refresh {
|
) -> Refresh {
|
||||||
let bounds = doc.bounds();
|
let bounds = doc.bounds();
|
||||||
let size = [doc.size.width, doc.size.height];
|
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 t0 = now_ms();
|
||||||
let mut out = Refresh::default();
|
let mut out = Refresh::default();
|
||||||
let region = damage.unwrap_or(bounds);
|
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
|
/// 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
|
/// 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>) {
|
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];
|
let size = [doc.size.width, doc.size.height];
|
||||||
if size[0] == 0 || size[1] == 0 {
|
if size[0] == 0 || size[1] == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let format = self.format_for(doc.depth, size);
|
||||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||||
{
|
{
|
||||||
let mut renderer = self.rs.renderer.write();
|
let mut renderer = self.rs.renderer.write();
|
||||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||||
if res.docs.get(&key).is_none_or(|d| d.size != size) {
|
if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) {
|
||||||
let tex = DocTextures::new(device, res, size, self.tile);
|
let tex = DocTextures::new(device, res, size, self.tile, format);
|
||||||
res.docs.insert(key, tex);
|
res.docs.insert(key, tex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut rgba = Vec::new();
|
||||||
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
|
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 renderer = self.rs.renderer.read();
|
||||||
let d = renderer.callback_resources.get::<Resources>().and_then(|r| r.docs.get(&key)).ok_or(())?;
|
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);
|
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);
|
res.luts.remove(&doc);
|
||||||
}
|
}
|
||||||
Some(bytes) => {
|
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);
|
let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes);
|
||||||
res.luts.insert(doc, bg);
|
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).
|
/// 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) {
|
pub fn paint(painter: &egui::Painter, rect: egui::Rect, params: ViewParams) {
|
||||||
painter.add(egui_wgpu::Callback::new_paint_callback(rect, CanvasCallback { rect, params }));
|
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.
|
/// Straight-alpha f32 RGBA (sRGB-encoded) → premultiplied RGBA8, split across threads on native.
|
||||||
pub fn premultiply_rgba8(px: &[[f32; 4]]) -> Vec<u8> {
|
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) {
|
for (o, p) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) {
|
||||||
let a = p[3].clamp(0.0, 1.0);
|
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;
|
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;
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
if px.len() > 1 << 18 {
|
if px.len() > 1 << 18 {
|
||||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get()).min(16);
|
let threads = std::thread::available_parallelism().map_or(4, |n| n.get()).min(16);
|
||||||
let chunk = px.len().div_ceil(threads);
|
let chunk = px.len().div_ceil(threads);
|
||||||
std::thread::scope(|s| {
|
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));
|
s.spawn(move || convert(src, dst));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return out;
|
return;
|
||||||
}
|
}
|
||||||
convert(px, &mut out);
|
convert(px, out);
|
||||||
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,
|
sampler: wgpu::Sampler,
|
||||||
mip_bgl: wgpu::BindGroupLayout,
|
mip_bgl: wgpu::BindGroupLayout,
|
||||||
mip_pipeline: wgpu::RenderPipeline,
|
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,
|
mip_sampler: wgpu::Sampler,
|
||||||
docs: HashMap<u64, DocTextures>,
|
docs: HashMap<u64, DocTextures>,
|
||||||
views: HashMap<u64, ViewGpu>,
|
views: HashMap<u64, ViewGpu>,
|
||||||
|
|
@ -613,6 +855,7 @@ struct ViewGpu {
|
||||||
|
|
||||||
struct DocTextures {
|
struct DocTextures {
|
||||||
size: [u32; 2],
|
size: [u32; 2],
|
||||||
|
format: wgpu::TextureFormat,
|
||||||
tiles: Vec<Tile>,
|
tiles: Vec<Tile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -686,7 +929,7 @@ fn pipeline(
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Resources {
|
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 =
|
let module =
|
||||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_canvas"), source: wgpu::ShaderSource::Wgsl(CANVAS_WGSL.into()) });
|
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;
|
let vis = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT;
|
||||||
|
|
@ -748,6 +991,7 @@ impl Resources {
|
||||||
let mip_layout =
|
let mip_layout =
|
||||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_mip"), bind_group_layouts: &[Some(&mip_bgl)], immediate_size: 0 });
|
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 = 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 {
|
let mip_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
label: Some("pc_mip"),
|
label: Some("pc_mip"),
|
||||||
mag_filter: wgpu::FilterMode::Linear,
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
|
@ -777,6 +1021,7 @@ impl Resources {
|
||||||
immediate_size: 0,
|
immediate_size: 0,
|
||||||
});
|
});
|
||||||
let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None);
|
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());
|
let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes());
|
||||||
Self {
|
Self {
|
||||||
lut_bgl,
|
lut_bgl,
|
||||||
|
|
@ -790,6 +1035,8 @@ impl Resources {
|
||||||
sampler,
|
sampler,
|
||||||
mip_bgl,
|
mip_bgl,
|
||||||
mip_pipeline,
|
mip_pipeline,
|
||||||
|
mip_pipeline_high,
|
||||||
|
encode_pipeline_high,
|
||||||
mip_sampler,
|
mip_sampler,
|
||||||
docs: HashMap::new(),
|
docs: HashMap::new(),
|
||||||
views: HashMap::new(),
|
views: HashMap::new(),
|
||||||
|
|
@ -800,6 +1047,16 @@ impl Resources {
|
||||||
encode_pipeline,
|
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 {
|
fn mip_count(w: u32, h: u32) -> u32 {
|
||||||
|
|
@ -807,7 +1064,7 @@ fn mip_count(w: u32, h: u32) -> u32 {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DocTextures {
|
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();
|
let mut tiles = Vec::new();
|
||||||
for ty in (0..size[1]).step_by(tile as usize) {
|
for ty in (0..size[1]).step_by(tile as usize) {
|
||||||
for tx in (0..size[0]).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,
|
mip_level_count: levels_n,
|
||||||
sample_count: 1,
|
sample_count: 1,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
dimension: wgpu::TextureDimension::D2,
|
||||||
format: FORMAT,
|
format,
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||||
|
| wgpu::TextureUsages::COPY_DST
|
||||||
|
| wgpu::TextureUsages::COPY_SRC
|
||||||
|
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
let levels = (0..levels_n)
|
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 });
|
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
|
/// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride
|
||||||
/// overlapping tile's level 0.
|
/// `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]) {
|
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]);
|
let (x0, y0, x1, y1) = (origin[0], origin[1], origin[0] + size[0], origin[1] + size[1]);
|
||||||
for t in &self.tiles {
|
for t in &self.tiles {
|
||||||
let [tx, ty, tw, th] = t.rect;
|
let [tx, ty, tw, th] = t.rect;
|
||||||
|
|
@ -856,7 +1117,7 @@ impl DocTextures {
|
||||||
if ix0 >= ix1 || iy0 >= iy1 {
|
if ix0 >= ix1 || iy0 >= iy1 {
|
||||||
continue;
|
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(
|
queue.write_texture(
|
||||||
wgpu::TexelCopyTextureInfo {
|
wgpu::TexelCopyTextureInfo {
|
||||||
texture: &t.texture,
|
texture: &t.texture,
|
||||||
|
|
@ -864,8 +1125,8 @@ impl DocTextures {
|
||||||
origin: wgpu::Origin3d { x: ix0 - tx, y: iy0 - ty, z: 0 },
|
origin: wgpu::Origin3d { x: ix0 - tx, y: iy0 - ty, z: 0 },
|
||||||
aspect: wgpu::TextureAspect::All,
|
aspect: wgpu::TextureAspect::All,
|
||||||
},
|
},
|
||||||
rgba,
|
texels,
|
||||||
wgpu::TexelCopyBufferLayout { offset, bytes_per_row: Some(stride * 4), rows_per_image: Some(iy1 - iy0) },
|
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 },
|
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.
|
/// 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]) {
|
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") });
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_mips") });
|
||||||
for t in &self.tiles {
|
for t in &self.tiles {
|
||||||
let [tx, ty, tw, th] = t.rect;
|
let [tx, ty, tw, th] = t.rect;
|
||||||
|
|
@ -907,7 +1169,7 @@ impl DocTextures {
|
||||||
occlusion_query_set: None,
|
occlusion_query_set: None,
|
||||||
multiview_mask: None,
|
multiview_mask: None,
|
||||||
});
|
});
|
||||||
pass.set_pipeline(&res.mip_pipeline);
|
pass.set_pipeline(mip_pipeline);
|
||||||
pass.set_bind_group(0, &bg, &[]);
|
pass.set_bind_group(0, &bg, &[]);
|
||||||
pass.set_scissor_rect(x0, y0, x1 - x0, y1 - y0);
|
pass.set_scissor_rect(x0, y0, x1 - x0, y1 - y0);
|
||||||
pass.draw(0..3, 0..1);
|
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 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 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);
|
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[0] as f32,
|
||||||
screen[1] as f32,
|
screen[1] as f32,
|
||||||
|
|
@ -978,11 +1245,11 @@ impl CanvasCallback {
|
||||||
d[0],
|
d[0],
|
||||||
d[1],
|
d[1],
|
||||||
d[2],
|
d[2],
|
||||||
0.0,
|
gain,
|
||||||
g[0],
|
g[0],
|
||||||
g[1],
|
g[1],
|
||||||
g[2],
|
g[2],
|
||||||
0.0,
|
inv_gamma,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1068,8 +1335,8 @@ struct View {
|
||||||
c: vec4<f32>, // filter mode, lod, grid alpha, checker square (device px)
|
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
|
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
|
e: vec4<f32>, // checker light rgb, gamut warning opacity
|
||||||
f: vec4<f32>, // checker dark rgb
|
f: vec4<f32>, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off)
|
||||||
g: vec4<f32>, // gamut warning rgb
|
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
|
||||||
};
|
};
|
||||||
struct Tile { r: vec4<f32> }; // x, y, w, h in doc px
|
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));
|
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 ---------------------------------------------------------------------------
|
// --- drop shadow ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn erf2(x: vec2<f32>) -> vec2<f32> {
|
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));
|
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);
|
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) {
|
if (view.d.z > 0.5 && col.a > 0.0) {
|
||||||
// Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags
|
// Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags
|
||||||
// out-of-gamut colours).
|
// out-of-gamut colours).
|
||||||
|
|
@ -1204,7 +1484,7 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
|
||||||
|
|
||||||
const ENCODE_WGSL: &str = r#"
|
const ENCODE_WGSL: &str = r#"
|
||||||
@group(0) @binding(0) var acc: texture_2d<f32>;
|
@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
|
@vertex
|
||||||
fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
|
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> {
|
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
let c = textureLoad(acc, vec2<i32>(floor(pos.xy)) - offset.xy, 0);
|
let c = textureLoad(acc, vec2<i32>(floor(pos.xy)) - offset.xy, 0);
|
||||||
let a = clamp(c.a, 0.0, 1.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) {
|
if (offset.z != 0) {
|
||||||
// Linear composite: store its sRGB encoding (8-bit precision where it matters).
|
// Linear composite: store its sRGB encoding (8-bit precision where it matters).
|
||||||
rgb = srgb_encode(rgb);
|
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]);
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn shaders_validate() {
|
fn shaders_validate() {
|
||||||
|
|
|
||||||
|
|
@ -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})");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue