Files
photocraft/crates/gpu/tests/vector_mask.rs
T
Brandon ThomasandClaude Opus 5.5 66d5a5381e GPU: adapters without float render targets keep the GPU path for effect-free documents
Once Format stopped failing first, the Linux runner reached the GPU tests
for the first time: its software adapter (GL) can't render Rgba32Float or
R32Float. The app already picks Rgba16Float there, but the effect-map
pipelines (R32Float/R16Float) still panicked. They are now built inside an
error scope; where they can't be, documents with layer effects use the CPU
compositor and everything else stays on the GPU.

The exact-parity tests skip on adapters without 32-bit float targets (the
app uses the CPU compositor there), like they already skip without an
adapter; the fallback-path tests build with try_new_with_format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:55:25 -04:00

95 lines
4.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Vector masks on the GPU: the planner samples the layer's combined mask (pixel mask × the
//! rasterised path, `compose::masks::combined_mask`); results must match the CPU compositor.
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Adjustment, Document, Effect, Layer, LayerContent, LayerMask, Path, Size, Subpath, VectorMask};
use photocraft_geom::Rect;
fn gpu() -> Option<(wgpu::Device, wgpu::Queue, photocraft_gpu::Compositor)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
// Exact comparison needs 32-bit float targets (see parity.rs); skip on adapters without them.
if photocraft_gpu::Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
return None;
}
let comp = photocraft_gpu::Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
Some((device, queue, comp))
}
fn check(g: &mut (wgpu::Device, wgpu::Queue, photocraft_gpu::Compositor), d: &Document, what: &str) {
let cpu = photocraft_compose::flatten(d);
let out = photocraft_gpu::render_to_vec(&mut g.2, &g.0, &g.1, d, d.bounds()).unwrap_or_else(|e| panic!("{what}: {e}"));
let worst = cpu
.px
.iter()
.zip(&out)
.map(|(c, o)| (0..4).map(|k| if k == 3 { (c[3] - o[3]).abs() } else { (c[k] * c[3] - o[k] * o[3]).abs() }).fold(0.0f32, f32::max))
.fold(0.0f32, f32::max);
assert!(worst <= 1.0 / 255.0, "{what}: max diff {:.2}/255", worst * 255.0);
}
fn tri(inverted: bool, density: f32) -> VectorMask {
let mut p = Path::new(vec![Subpath::polygon(&[(3.5, 2.0), (40.0, 6.3), (12.0, 30.7)])]);
p.inverted = inverted;
let mut vm = VectorMask::new(p);
vm.density = density;
vm
}
fn painted(name: &str, fmt: PixelFormat, rgba: [f32; 4]) -> Layer {
let mut l = Layer::raster(name, fmt);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 44, 34), &photocraft_raster::from_rgba(&fmt, rgba));
l
}
#[test]
fn vector_masks_match_the_cpu() {
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16] {
for (inverted, density) in [(false, 1.0), (true, 1.0), (false, 0.6)] {
let mut d = Document::with_background("v", Size::new(44, 34), ColorMode::Rgb, depth, Color::WHITE);
let fmt = d.pixel_format();
// Raster layer with a vector mask and a pixel mask.
let mut l = painted("a", fmt, [0.9, 0.2, 0.1, 0.8]);
l.vector_mask = Some(tri(inverted, density));
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(20, 0, 44, 34), &[0.3]);
m.density = 0.9;
l.mask = Some(m);
l.blend = BlendMode::Multiply;
d.layers.push(l);
// A group (isolated and pass-through) with a vector mask.
let mut inner = painted("b", fmt, [0.1, 0.6, 0.9, 1.0]);
inner.opacity = 0.7;
let mut grp = Layer::group("g", vec![inner]);
grp.vector_mask = Some(tri(!inverted, density));
grp.blend = BlendMode::Normal;
d.layers.push(grp.clone());
grp.blend = BlendMode::PassThrough;
d.layers.push(grp);
// An adjustment layer and an effect layer with vector masks.
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
adj.vector_mask = Some(tri(inverted, density));
d.layers.push(adj);
let mut fx = painted("fx", fmt, [0.2, 0.8, 0.3, 1.0]);
fx.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 44, 34), &photocraft_raster::from_rgba(&fmt, [0.0; 4]));
fx.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 30, 26), &photocraft_raster::from_rgba(&fmt, [0.2, 0.8, 0.3, 1.0]));
fx.vector_mask = Some(tri(inverted, density));
fx.effects.items = vec![Effect::default_drop_shadow()];
d.layers.push(fx);
check(&mut g, &d, &format!("{depth:?} inverted {inverted} density {density}"));
// Editing the path re-rasterises the mask.
if let Some(vm) = &mut d.layers[1].vector_mask {
vm.path.subpaths[0].knots[1].anchor.x = 30.0;
vm.path.subpaths[0].knots[1].in_ctrl.x = 30.0;
vm.path.subpaths[0].knots[1].out_ctrl.x = 30.0;
}
check(&mut g, &d, "edited path");
// Disabling it shows the layer unmasked by the path.
d.layers[1].vector_mask.as_mut().unwrap().enabled = false;
check(&mut g, &d, "disabled");
}
}
}