Files
photocraft/crates/gpu/tests/it/vector_mask.rs
T
aiongg ffa400c754 Keep tests correct and docs current with one test binary per crate
- gpu: the test modules share one GPU_LOCK, and timing and vector_mask
  take it too, so one process drives one wgpu device at a time.
- ui-egui: the wgpu tests in pattern_preview_canvas, adjust_preview_gpu,
  marquee_autoscroll and layout_perf take gpu_lock().
- affinity compound_limits: the child process selects its test by the
  module path (compound_limits::<name>); with --exact, the bare name
  matches no test and the child passes without running the check.
- xtask scorecard reads crates/io/tests/it/corpus.rs.
- Docs and comments name the new test paths and the --test it filters.
2026-10-22 16:55:34 +00:00

98 lines
5.0 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);
// 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 _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
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");
}
}
}