From ae7d802726809e0f51701c9eaef69e201bb5a52a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 02:27:54 -0400 Subject: [PATCH] GPU: fall back to Rgba16Float when Rgba32Float isn't renderable (#7, #4) (#22) Some drivers (e.g. Intel Vulkan on Linux/Windows) can't render to Rgba32Float, so creating the 'fs_content' pipeline panicked with 'Format Rgba32Float is not renderable' as soon as a document/image was opened. The accumulation format is now chosen per adapter: Compositor::preferred_acc_format() keeps Rgba32Float where it's a valid render target and falls back to Rgba16Float otherwise. The compositor carries the format (pipelines, accumulation texture, byte size and the readback half-decode all follow it); the app picks it from the eframe adapter. Capable GPUs are unchanged (parity still exact, 18/18). A new test forces the Rgba16Float path on any GPU and confirms it renders within display tolerance (blend + adjustment). wasm green. Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 1 + crates/gpu/Cargo.toml | 1 + crates/gpu/src/lib.rs | 44 +++++++++++++++++++++++++++----- crates/gpu/tests/parity.rs | 35 +++++++++++++++++++++++++ crates/ui-egui/src/gpu_canvas.rs | 2 +- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e4ab103..6f2ebb50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3417,6 +3417,7 @@ dependencies = [ name = "photocraft-gpu" version = "0.1.1" dependencies = [ + "half", "log", "photocraft-color", "photocraft-compose", diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc08bcd3..6a0b649c 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true workspace = true [dependencies] +half = "2" photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-raster = { workspace = true } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a4ac02f6..78f24a57 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -43,7 +43,7 @@ pub use plan::{Kernel, Plan, Role, Unsupported, plan}; /// Accumulator format for intermediate buffers. Full float: discontinuous operations /// (Posterize, Threshold, Hard Mix, Dissolve) must land on the same side of their thresholds as /// the CPU reference, and Color Burn/Dodge amplify input error. -pub const ACC_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; +pub const ACC_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; // default accumulation format; `preferred_acc_format` downgrades to Rgba16Float where 32-bit float isn't renderable /// Effect intermediates (distances, blurs) and layer shapes. const MAP32: wgpu::TextureFormat = wgpu::TextureFormat::R32Float; /// Final effect coverage maps (0..1; half precision is ~1/4000). @@ -181,6 +181,7 @@ impl Tex { let bpp = match self.texture.format() { wgpu::TextureFormat::R16Float => 2, wgpu::TextureFormat::Rgba32Float => 16, + wgpu::TextureFormat::Rgba16Float => 8, _ => 4, }; s.width as usize * s.height as usize * bpp @@ -310,6 +311,7 @@ pub struct Compositor { patterns: HashMap, max_dim: u32, frame: u64, + acc_format: wgpu::TextureFormat, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -343,7 +345,27 @@ struct Bound { } impl Compositor { + /// Create a compositor with the default accumulation format ([`ACC_FORMAT`]). pub fn new(device: &wgpu::Device) -> Self { + Self::new_with_format(device, ACC_FORMAT) + } + + /// The accumulation format to use on `adapter`: `Rgba32Float` when it can be a render target, + /// else `Rgba16Float`. Some drivers (e.g. Intel Vulkan) don't support rendering to `Rgba32Float` + /// and would otherwise panic at render-pipeline creation. + pub fn preferred_acc_format(adapter: &wgpu::Adapter) -> wgpu::TextureFormat { + let feats = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba32Float); + if feats.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) { + wgpu::TextureFormat::Rgba32Float + } else { + wgpu::TextureFormat::Rgba16Float + } + } + + /// Create a compositor whose accumulation/render-target format is `acc_format`. Pass + /// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers), + /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. + pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_uniforms"), entries: &[uniform_entry(0, CHUNK_UNIFORM), uniform_entry(1, OP_UNIFORM)] }); let bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_textures"), entries: &(0..8).map(tex_entry).collect::>() }); @@ -351,7 +373,7 @@ impl Compositor { let mut pipelines = HashMap::new(); for k in Kernel::DRAWN { let entry = k.entry().expect("drawn kernels have an entry point"); - let formats: &[wgpu::TextureFormat] = if k.is_map() { &[MAP32, MAP16] } else { &[ACC_FORMAT] }; + let formats: &[wgpu::TextureFormat] = if k.is_map() { &[MAP32, MAP16] } else { &[acc_format] }; for &format in formats { let p = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(entry), @@ -377,6 +399,7 @@ impl Compositor { patterns: HashMap::new(), max_dim: device.limits().max_texture_dimension_2d, frame: 0, + acc_format, } } @@ -532,7 +555,7 @@ impl Compositor { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: ACC_FORMAT, + format: self.acc_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); @@ -621,7 +644,7 @@ impl Compositor { if clear { continue; } - pass.set_pipeline(&self.kit.pipelines[&(p.kernel, ACC_FORMAT)]); + pass.set_pipeline(&self.kit.pipelines[&(p.kernel, self.acc_format)]); pass.set_scissor_rect(scissor.x0 as u32, scissor.y0 as u32, scissor.width(), scissor.height()); pass.set_bind_group(0, &bg0, &[(ci as u64 * STRIDE) as u32, (op_base + i as u64 * STRIDE) as u32]); pass.set_bind_group(1, bg1[i].as_ref(), &[]); @@ -1109,8 +1132,11 @@ pub fn render_to_vec(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu: pub fn render_to_vec_stats(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu::Queue, doc: &Document, rect: Rect) -> Result<(Vec<[f32; 4]>, Stats), Unsupported> { let rect = rect.intersect(&doc.bounds()); let mut staging: Vec<(Rect, wgpu::Buffer, u32)> = Vec::new(); + let mut bpp = 16u32; let stats = comp.render(device, queue, doc, rect, |enc, out| { - let row = (out.rect.width() * 16).div_ceil(256) * 256; + // Bytes per pixel of the accumulation format (Rgba32Float = 16, Rgba16Float = 8). + bpp = if out.texture.format() == wgpu::TextureFormat::Rgba32Float { 16 } else { 8 }; + let row = (out.rect.width() * bpp).div_ceil(256) * 256; let buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("pc_readback"), size: (row * out.rect.height()) as u64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: out.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, @@ -1129,8 +1155,12 @@ pub fn render_to_vec_stats(comp: &mut Compositor, device: &wgpu::Device, queue: let data = b.slice(..).get_mapped_range().expect("mapped readback buffer"); for y in 0..r.height() as usize { for x in 0..r.width() as usize { - let o = y * *row as usize + x * 16; - let px: [f32; 4] = std::array::from_fn(|i| f32::from_le_bytes(data[o + i * 4..o + i * 4 + 4].try_into().expect("4 bytes"))); + let o = y * *row as usize + x * bpp as usize; + let px: [f32; 4] = if bpp == 16 { + std::array::from_fn(|i| f32::from_le_bytes(data[o + i * 4..o + i * 4 + 4].try_into().expect("4 bytes"))) + } else { + std::array::from_fn(|i| half::f16::from_le_bytes([data[o + i * 2], data[o + i * 2 + 1]]).to_f32()) + }; let (dx, dy) = ((r.x0 - rect.x0) as usize + x, (r.y0 - rect.y0) as usize + y); out[dy * w + dx] = px; } diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 750d0e0b..aa42e211 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -961,3 +961,38 @@ fn lab_documents_mix_in_lab() { let p = photocraft_compose::flatten(&d).px[0]; assert!((p[0] - 0.5).abs() > 0.05 || (p[2] - 0.5).abs() > 0.05, "{p:?}"); } + +#[test] +fn rgba16f_fallback_path_renders() { + // Issue #7 / #4: on adapters that can't render Rgba32Float the compositor falls back to + // Rgba16Float. Force that path here (even on a 32f-capable GPU) to prove it works end to end — + // pipeline creation, the accumulation texture, and the half-float readback — within display + // tolerance of the CPU reference. This is the path Intel-Vulkan / limited GPUs take. + let instance = wgpu::Instance::default(); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { + return; + }; + let Some((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok() else { + return; + }; + let mut comp = Compositor::new_with_format(&device, wgpu::TextureFormat::Rgba16Float); + let mut d = base_doc(48, 32); + let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 48, 32), 7, 1.0); + top.blend = BlendMode::Multiply; + top.opacity = 0.8; + d.layers.push(top); + d.layers.push(Layer::new("adj", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 30.0, contrast: 40.0, legacy: false }))); + let cpu = photocraft_compose::flatten(&d); + let out = render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).expect("16f render"); + let mut worst = 0.0f32; + for (c, o) in cpu.px.iter().zip(&out) { + for k in 0..4 { + worst = worst.max((c[k] * c[3] - o[k] * o[3]).abs()); + } + } + // Half-float display precision — looser than the exact 1/255 parity bar, but proves the path works. + assert!(worst <= 3.0 / 255.0, "16f fallback max diff {:.2}/255", worst * 255.0); + // preferred_acc_format returns a renderable format for this adapter. + let f = Compositor::preferred_acc_format(&adapter); + assert!(matches!(f, wgpu::TextureFormat::Rgba32Float | wgpu::TextureFormat::Rgba16Float)); +} diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index d5f2cb3d..65df3245 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -160,7 +160,7 @@ impl GpuCanvas { } let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return Err(photocraft_gpu::Unsupported("no GPU canvas".into())) }; - let mut comp = res.compositor.take().unwrap_or_else(|| photocraft_gpu::Compositor::new(device)); + let mut comp = res.compositor.take().unwrap_or_else(|| photocraft_gpu::Compositor::new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter))); let key = doc.id.0; let fresh = res.docs.get(&key).is_none_or(|d| d.size != size); let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) };