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 <noreply@anthropic.com>
This commit is contained in:
parent
16ce03877c
commit
ae7d802726
|
|
@ -3417,6 +3417,7 @@ dependencies = [
|
|||
name = "photocraft-gpu"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ license.workspace = true
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
half = "2"
|
||||
photocraft-geom = { workspace = true }
|
||||
photocraft-color = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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<String, (u64, Tex)>,
|
||||
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::<Vec<_>>() });
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ impl GpuCanvas {
|
|||
}
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() 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()) };
|
||||
|
|
|
|||
Loading…
Reference in New Issue