mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 18:36:33 +02:00
* Speed up large feathered mask refreshes * Format preference label for workspace check * gpu test: allow float noise on a one-code vector-mask parity step The combined mask is cached as U16 now; one pixel lands exactly one 8-bit code apart (1.0000075/255), within the <=1/255 parity rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
4.9 KiB
Rust
97 lines
4.9 KiB
Rust
//! 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);
|
||
// One 8-bit code at most; the epsilon absorbs float noise on an exact one-code step
|
||
// (the combined mask is cached as U16 since #2417).
|
||
assert!(worst <= 1.0 / 255.0 + 1e-6, "{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");
|
||
}
|
||
}
|
||
}
|