Files
photocraft/crates/gpu/tests/parity.rs
T

1481 lines
71 KiB
Rust

//! GPU vs CPU parity: every blend mode, adjustment, group/clip/mask/fill combination must match
//! the reference compositor within 2/255 (premultiplied). Skips when no GPU adapter exists.
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel};
use photocraft_doc::{Adjustment, Document, Fill, GradientStyle, Layer, LayerContent, LayerMask};
use photocraft_geom::{Rect, Size};
use photocraft_gpu::{Compositor, render_to_vec};
const TOL: f32 = 1.0 / 255.0;
/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so the GPU tests
/// take this lock and hold it until their device is dropped.
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
comp: Compositor,
/// The same device with a simulated texture limit of [`PAGED_LIMIT`]: every check also runs
/// through layer pages and per-cell effect maps.
paged: Compositor,
_lock: std::sync::MutexGuard<'static, ()>,
}
/// Simulated texture limit (pages and chunks of 256 px).
const PAGED_LIMIT: u32 = 256;
fn gpu() -> Option<Gpu> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let instance = wgpu::Instance::default();
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
Ok(a) => a,
Err(e) => {
eprintln!("skipping GPU parity tests: no adapter ({e})");
return None;
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
// Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software
// rasterizers) use the CPU compositor in the app, so there is nothing to compare there.
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float");
return None;
}
let comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) {
Ok(c) => c,
Err(e) => {
eprintln!("skipping GPU parity tests: {e}");
return None;
}
};
let mut paged = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
paged.set_texture_limit(PAGED_LIMIT);
Some(Gpu { device, queue, comp, paged, _lock: lock })
}
/// Any adapter and device, for the fallback-path tests (which don't need 32-bit float targets).
/// The returned guard holds [`GPU_LOCK`] until the device is dropped.
fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue, std::sync::MutexGuard<'static, ()>)> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
Some((adapter, device, queue, lock))
}
/// Deterministic pseudo-random values in 0..1.
fn rnd(seed: u32, i: u32) -> f32 {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
h ^= h >> 13;
h = h.wrapping_mul(0xc2b2_ae35);
h ^= h >> 16;
(h & 0xffff) as f32 / 65535.0
}
fn noise_layer(name: &str, fmt: PixelFormat, rect: Rect, seed: u32, min_alpha: f32) -> Layer {
let mut l = Layer::raster(name, fmt);
let n = rect.width() as usize * rect.height() as usize;
let ch = fmt.channels();
let mut data = Vec::with_capacity(n * ch);
for i in 0..n as u32 {
for c in 0..ch {
let v = rnd(seed + c as u32 * 7919, i);
let is_alpha = fmt.alpha && c == ch - 1;
data.push(if is_alpha { min_alpha + (1.0 - min_alpha) * v } else { v });
}
}
l.surface_mut().unwrap().write_region(rect, &data);
l
}
fn mask(rect: Rect, seed: u32, default: f32) -> LayerMask {
let mut m = LayerMask::reveal_all();
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[default]);
let data: Vec<f32> = (0..rect.width() * rect.height()).map(|i| rnd(seed, i)).collect();
m.surface.write_region(rect, &data);
m.density = 0.8;
m
}
fn base_doc(w: u32, h: u32) -> Document {
let mut d = Document::new("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
d.layers.push(noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, w, h), 1, 0.3));
d
}
/// Largest premultiplied difference between `cpu` and `gpu` pixels, and its index.
fn worst_diff(cpu: &[[f32; 4]], gpu: &[[f32; 4]]) -> (f32, usize) {
let mut worst = (0.0f32, 0usize);
for (i, (c, o)) in cpu.iter().zip(gpu).enumerate() {
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
for k in 0..4 {
let d = (pc[k] - po[k]).abs();
if d.is_nan() || d > worst.0 {
worst = (if d.is_nan() { 9.0 } else { d }, i);
}
}
}
worst
}
/// GPU (whole textures, then pages) vs CPU over `rect`: Err with the worst pixel if over the
/// tolerance, else the stats of the unpaged render.
fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result<photocraft_gpu::Stats, String> {
let cpu = photocraft_compose::render(doc, rect);
let mut first = None;
for (label, comp) in [("", &mut g.comp), (" (paged)", &mut g.paged)] {
let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect).map_err(|e| format!("{what}{label}: {e}"))?;
let worst = worst_diff(&cpu.px, &out);
let w = rect.width() as usize;
let (x, y) = (rect.x0 + (worst.1 % w) as i32, rect.y0 + (worst.1 / w) as i32);
if worst.0 > TOL {
return Err(format!("{what}{label}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
}
first.get_or_insert(stats);
}
first.ok_or_else(|| format!("{what}: no render"))
}
fn check(g: &mut Gpu, doc: &Document, what: &str) {
diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
}
#[test]
fn first_frame_renders() {
// Regression: the first submission after device creation is dropped on some drivers
// (RADV), which used to make the very first render come back all zeroes.
let Some(mut g) = gpu() else { return };
check(&mut g, &base_doc(64, 48), "first frame");
}
#[test]
fn blend_modes() {
let Some(mut g) = gpu() else { return };
for mode in BlendMode::LAYER_MODES {
let mut d = base_doc(64, 48);
let mut l = noise_layer("top", PixelFormat::RGBA8, Rect::new(4, 3, 60, 45), 2, 0.0);
l.blend = mode;
l.opacity = 0.8;
l.fill_opacity = 0.9;
d.layers.push(l);
check(&mut g, &d, &format!("{mode:?}"));
// Over an opaque backdrop too (the common case).
d.layers[0] = noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 5, 1.0);
check(&mut g, &d, &format!("{mode:?} opaque"));
}
}
fn hue_ranges() -> [HueRange; 6] {
let mut r = HueRange::defaults();
r[0] = HueRange { hue: 20.0, saturation: -50.0, lightness: 10.0, ..HueRange::neutral(0) };
r[4] = HueRange { hue: -30.0, saturation: 40.0, lightness: -20.0, bounds: [180.0, 220.0, 260.0, 300.0] };
r
}
fn adjustments() -> Vec<Adjustment> {
let lc = |a: f32, b: f32, g: f32| LevelsChannel { in_black: a, in_white: b, gamma: g, out_black: 0.05, out_white: 0.95 };
let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::<Vec<_>>();
vec![
Adjustment::Invert,
Adjustment::Threshold { level: 0.5 },
Adjustment::Posterize { levels: 5 },
Adjustment::BrightnessContrast { brightness: 30.0, contrast: 40.0, legacy: false },
Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true },
Adjustment::Exposure { exposure: 0.7, offset: 0.02, gamma: 1.2 },
Adjustment::Levels {
master: lc(0.1, 0.9, 1.3),
per_channel: [lc(0.0, 1.0, 0.8), LevelsChannel::default(), lc(0.2, 0.8, 1.0)],
space: Default::default(),
black: LevelsChannel::default(),
},
Adjustment::Curves {
master: pts(&[(0.0, 0.1), (0.4, 0.6), (1.0, 0.9)]),
per_channel: [pts(&[(0.0, 0.0), (0.5, 0.3), (1.0, 1.0)]), pts(&[(0.0, 0.0), (1.0, 1.0)]), pts(&[(0.0, 0.2), (1.0, 1.0)])],
space: Default::default(),
black: Vec::new(),
},
Adjustment::HueSaturation { hue: 40.0, saturation: 30.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() },
Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 20.0, colorize: true, ranges: HueRange::defaults() },
Adjustment::HueSaturation { hue: -10.0, saturation: 10.0, lightness: 5.0, colorize: false, ranges: hue_ranges() },
Adjustment::Vibrance { vibrance: 50.0, saturation: -20.0 },
Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false },
Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.0], [0.0; 4], [0.0; 4]], monochrome: true },
Adjustment::PhotoFilter { color: [0.9, 0.6, 0.2], density: 0.4, preserve_luminosity: true },
Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false },
Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None },
Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) },
Adjustment::GradientMap { stops: vec![(0.0, [0.1, 0.0, 0.3]), (0.5, [0.9, 0.3, 0.1]), (1.0, [1.0, 1.0, 0.8])], reverse: false, dither: false },
Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true, dither: true },
Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -20.0], preserve_luminosity: true },
Adjustment::ColorBalance { shadows: [10.0, 0.0, 0.0], midtones: [0.0, 0.0, 0.0], highlights: [0.0, 0.0, 10.0], preserve_luminosity: false },
Adjustment::SelectiveColor { relative: true, adjustments: selective() },
Adjustment::SelectiveColor { relative: false, adjustments: selective() },
lookup(17, false, false),
lookup(5, true, false),
lookup(33, false, true),
]
}
fn selective() -> [[f32; 4]; 9] {
std::array::from_fn(|r| std::array::from_fn(|k| ((r * 4 + k) as f32 * 37.0) % 200.0 - 100.0))
}
/// A warped (non-identity) 3D LUT so interpolation differences show.
fn lookup(n: usize, tetrahedral: bool, dither: bool) -> Adjustment {
let mut t = Vec::with_capacity(n * n * n * 3);
for b in 0..n {
for g in 0..n {
for r in 0..n {
let (r, g, b) = (r as f32 / (n - 1) as f32, g as f32 / (n - 1) as f32, b as f32 / (n - 1) as f32);
t.extend([(r * r * 0.8 + b * 0.2).min(1.0), g.sqrt(), (1.0 - r) * 0.3 + b * 0.7]);
}
}
}
Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither }
}
#[test]
fn adjustment_layers() {
let Some(mut g) = gpu() else { return };
// 16-bit and float documents (adjustment results round to 1/32768 resp. not at all).
for depth in [SampleType::U16, SampleType::F32] {
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, depth);
let fmt = d.pixel_format();
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 40, 30), 71, 0.5));
d.layers.push(Layer::new("ex", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.01, gamma: 1.1 })));
check(&mut g, &d, &format!("adjustment {depth:?}"));
// Levels on whole levels of the document's depth (compose::adjust::levels_q).
let lc = LevelsChannel { in_black: 0.17, in_white: 0.84, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
let lv = Adjustment::Levels { master: lc, per_channel: Default::default(), space: Default::default(), black: LevelsChannel::default() };
d.layers.push(Layer::new("lv", LayerContent::Adjustment(lv)));
check(&mut g, &d, &format!("levels {depth:?}"));
}
for adj in adjustments() {
for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] {
let mut d = base_doc(48, 40);
d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(8, 8, 40, 32), 3, 0.5));
let mut a = Layer::new("adj", LayerContent::Adjustment(adj.clone()));
a.blend = mode;
a.opacity = opacity;
if masked {
a.mask = Some(mask(Rect::new(0, 0, 48, 20), 9, 0.3));
}
d.layers.push(a);
check(&mut g, &d, &format!("{} {mode:?}", adj.label()));
}
}
}
#[test]
fn groups_masks_and_clipping() {
let Some(mut g) = gpu() else { return };
let child = |seed, mode| {
let mut l = noise_layer("c", PixelFormat::RGBA8, Rect::new(5, 5, 50, 40), seed, 0.0);
l.blend = mode;
l
};
for isolated in [false, true] {
let mut d = base_doc(56, 44);
let mut grp = Layer::group("g", vec![child(11, BlendMode::Normal), child(12, BlendMode::Screen), child(13, BlendMode::Multiply)]);
if isolated {
grp.blend = BlendMode::Overlay;
}
grp.opacity = 0.7;
grp.mask = Some(mask(Rect::new(10, 0, 40, 44), 21, 1.0));
d.layers.push(grp);
check(&mut g, &d, &format!("group isolated={isolated}"));
}
// Nested groups with an adjustment inside a pass-through group.
let mut d = base_doc(56, 44);
let inner = Layer::group("inner", vec![child(14, BlendMode::Normal), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
let mut outer = Layer::group("outer", vec![inner, child(15, BlendMode::Difference)]);
outer.blend = BlendMode::Normal;
d.layers.push(outer);
check(&mut g, &d, "nested groups");
// Clipping: raster + adjustment clipped to a base, a masked base, and a hidden base.
for hidden in [false, true] {
let mut d = base_doc(56, 44);
let mut base = noise_layer("base", PixelFormat::RGBA8, Rect::new(10, 6, 46, 38), 16, 0.0);
base.mask = Some(mask(Rect::new(0, 0, 56, 44), 22, 1.0));
base.visible = !hidden;
let mut c1 = child(17, BlendMode::Multiply);
c1.clipped = true;
c1.opacity = 0.6;
let mut c2 = Layer::new(
"hs",
LayerContent::Adjustment(Adjustment::HueSaturation { hue: 90.0, saturation: 20.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }),
);
c2.clipped = true;
d.layers.extend([base, c1, c2]);
check(&mut g, &d, &format!("clipping hidden={hidden}"));
}
// Clipped group base (isolated) with a clipped layer.
let mut d = base_doc(56, 44);
let mut gb = Layer::group("gb", vec![child(18, BlendMode::Normal)]);
gb.blend = BlendMode::Normal;
let mut c = child(19, BlendMode::Screen);
c.clipped = true;
d.layers.extend([gb, c]);
check(&mut g, &d, "clipped to isolated group");
// Layers clipped to a pass-through group (with opacity and a mask, children in modes).
for (op, masked) in [(1.0, false), (0.6, true)] {
let mut d = base_doc(56, 44);
let mut pt = Layer::group("pt", vec![child(31, BlendMode::Normal), child(32, BlendMode::Multiply)]);
pt.opacity = op;
if masked {
pt.mask = Some(mask(Rect::new(5, 0, 50, 44), 33, 1.0));
}
let mut c1 = child(34, BlendMode::Screen);
c1.clipped = true;
c1.opacity = 0.8;
let mut c2 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
c2.clipped = true;
d.layers.extend([pt, c1, c2]);
check(&mut g, &d, &format!("clipped to pass-through op {op} masked {masked}"));
}
}
#[test]
fn fills_and_dissolve() {
let Some(mut g) = gpu() else { return };
let mut d = base_doc(64, 48);
let mut solid = Layer::new("solid", LayerContent::Fill(Fill::Solid(Color::rgb(0.2, 0.5, 0.8))));
solid.mask = Some(mask(Rect::new(0, 0, 32, 48), 31, 0.0));
solid.blend = BlendMode::HardLight;
d.layers.push(solid);
check(&mut g, &d, "solid fill");
for style in [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond] {
let mut d = base_doc(64, 48);
let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.6, Color::rgb(0.0, 1.0, 0.2)), (1.0, Color::rgb(0.1, 0.1, 0.9))];
let mut l = Layer::new("grad", LayerContent::Fill(Fill::gradient(stops, 30.0, 0.8, style, style == GradientStyle::Radial)));
l.opacity = 0.9;
d.layers.push(l);
check(&mut g, &d, &format!("gradient {style:?}"));
// A live gradient (Gradient tool): canvas-aligned, offset, midpoints, opacity stops,
// dither, masked by a selection.
let mut d = base_doc(64, 48);
let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.6, Color::rgb(0.0, 1.0, 0.2)), (1.0, Color::rgb(0.1, 0.1, 0.9))];
let fill = Fill::Gradient {
stops,
angle: -20.0,
scale: 0.6,
style,
reverse: false,
opacity_stops: vec![(0.0, 1.0), (1.0, 0.3)],
midpoints: vec![0.3, 0.7],
offset: (0.15, -0.1),
dither: true,
align: false,
};
let mut l = Layer::new("live", LayerContent::Fill(fill));
l.mask = Some(mask(Rect::new(8, 4, 40, 40), 13, 0.0));
d.layers.push(l);
check(&mut g, &d, &format!("live gradient {style:?}"));
}
// Tiny frames, where the whole-pixel end points (compose::fill_layout) change the angle
// and centre.
for (w, h, style, angle) in [(4, 4, GradientStyle::Reflected, 30.0), (7, 5, GradientStyle::Linear, 30.0), (9, 4, GradientStyle::Linear, -60.0)] {
let mut d = base_doc(w, h);
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.7)), (0.5, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 0.0))];
d.layers.push(Layer::new("grad", LayerContent::Fill(Fill::gradient(stops, angle, 1.0, style, false))));
check(&mut g, &d, &format!("small gradient {w}x{h} {style:?} {angle}"));
}
// An opaque, dithered gradient over everything (the GPU skips the layers it hides), with a
// clipped layer and an adjustment above it.
let mut d = base_doc(64, 48);
d.layers.push(noise_layer("under", PixelFormat::RGBA8, Rect::new(5, 5, 50, 40), 17, 0.4));
let stops = vec![(0.0, Color::rgb(0.9, 0.2, 0.1)), (1.0, Color::rgb(0.1, 0.3, 0.9))];
let mut cover = Fill::gradient(stops, 70.0, 0.7, GradientStyle::Linear, false);
if let Fill::Gradient { dither, offset, .. } = &mut cover {
*dither = true;
*offset = (0.1, -0.05);
}
d.layers.push(Layer::new("cover", LayerContent::Fill(cover)));
let mut clip = noise_layer("clip", PixelFormat::RGBA8, Rect::new(10, 10, 30, 30), 23, 0.2);
clip.clipped = true;
d.layers.push(clip);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
check(&mut g, &d, "opaque gradient over everything");
let mut d = base_doc(64, 48);
let mut l = noise_layer("dis", PixelFormat::RGBA8, Rect::new(0, 0, 64, 48), 41, 0.2);
l.blend = BlendMode::Dissolve;
l.opacity = 0.6;
d.layers.push(l);
check(&mut g, &d, "dissolve");
}
#[test]
fn formats_offsets_and_chunks() {
let Some(mut g) = gpu() else { return };
// 16-bit and float layers, and a layer extending off-canvas at negative coordinates.
let mut d = Document::new("t", Size::new(300, 70), ColorMode::Rgb, SampleType::U16);
d.layers.push(noise_layer("bg16", PixelFormat::RGBA16, Rect::new(0, 0, 300, 70), 51, 1.0));
let mut f = noise_layer("f32", PixelFormat::RGBA32F, Rect::new(-40, -10, 200, 60), 52, 0.0);
f.blend = BlendMode::SoftLight;
d.layers.push(f);
check(&mut g, &d, "16-bit / float / offsets");
// Grayscale and CMYK documents.
let mut d = Document::new("g", Size::new(40, 30), ColorMode::Grayscale, SampleType::U8);
d.layers.push(noise_layer("g", PixelFormat::GRAYA8, Rect::new(0, 0, 40, 30), 53, 0.5));
check(&mut g, &d, "grayscale");
let mut d = Document::new("c", Size::new(40, 30), ColorMode::Cmyk, SampleType::U8);
d.layers.push(noise_layer("c", PixelFormat::CMYKA8, Rect::new(0, 0, 40, 30), 54, 1.0));
check(&mut g, &d, "cmyk");
// Wider than one chunk.
let w = photocraft_gpu::CHUNK + 100;
let mut d = base_doc(w, 24);
let mut l = noise_layer("top", PixelFormat::RGBA8, Rect::new(0, 0, w as i32, 24), 55, 0.0);
l.blend = BlendMode::Color;
d.layers.push(l);
check(&mut g, &d, "multi-chunk");
}
#[test]
fn incremental_updates_follow_the_document() {
let Some(mut g) = gpu() else { return };
let mut d = base_doc(600, 300);
d.layers.push(noise_layer("top", PixelFormat::RGBA8, Rect::new(0, 0, 600, 300), 61, 0.0));
check(&mut g, &d, "initial");
// Paint into one tile of the top layer: only that tile uploads.
let top = d.layers[1].surface_mut().unwrap();
top.fill_rect(Rect::new(10, 10, 60, 60), &[1.0, 0.0, 0.0, 1.0]);
let before = photocraft_gpu::render_to_vec(&mut g.comp, &g.device, &g.queue, &d, Rect::new(0, 0, 1, 1)).unwrap();
assert_eq!(before.len(), 1);
check(&mut g, &d, "after paint");
// Remove all tiles of the top layer, hide nothing: the GPU must clear them.
d.layers[1] = Layer::raster("empty", PixelFormat::RGBA8);
check(&mut g, &d, "after clearing");
// Visibility and opacity changes need no uploads.
d.layers[0].opacity = 0.5;
check(&mut g, &d, "opacity");
}
// ---- layer effects --------------------------------------------------------------------------
use photocraft_doc::adjust::CurvePoint as Cp;
use photocraft_doc::{
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Pattern, Satin, Shadow, StrokeFx,
StrokePosition,
};
/// An anti-aliased blob (disc plus a soft-edged bar and a hole) with partially transparent parts,
/// so effects see real edge coverage, concavities and interior alpha.
/// Small layers composite only over their bounds and are copied back into the backdrop (#125):
/// every blend mode, clipped layers, isolated and pass-through groups (with adjustments, masks,
/// opacity), layers partly off the canvas and over several chunks must still match the CPU.
#[test]
fn small_layers_composite_over_their_bounds() {
let Some(mut g) = gpu() else { return };
let small = |seed: u32, x: i32, y: i32, mode: BlendMode| {
let mut l = noise_layer("s", PixelFormat::RGBA8, Rect::from_xywh(x, y, 23, 17), seed, 0.0);
l.blend = mode;
l.opacity = 0.85;
l
};
let mut d = base_doc(200, 150);
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
let i = i as i32;
d.layers.push(small(100 + i as u32, (i * 29) % 190 - 8, (i * 37) % 140 - 5, mode));
}
// A clipping base with a raster and an adjustment clipped to it.
let base = small(200, 120, 90, BlendMode::Normal);
let mut c1 = noise_layer("c1", PixelFormat::RGBA8, Rect::new(100, 80, 190, 140), 201, 0.0);
c1.clipped = true;
c1.blend = BlendMode::Multiply;
let mut c2 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
c2.clipped = true;
d.layers.extend([base, c1, c2]);
// An isolated group with a mask, an adjustment and a nested pass-through group.
let inner = Layer::group("inner", vec![small(300, 40, 100, BlendMode::Screen), small(301, 55, 110, BlendMode::Normal)]);
let mut iso = Layer::group(
"iso",
vec![
small(302, 30, 95, BlendMode::Normal),
Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)),
inner,
small(303, 70, 120, BlendMode::Overlay),
],
);
iso.blend = BlendMode::Normal;
iso.opacity = 0.75;
iso.mask = Some(mask(Rect::new(25, 90, 100, 145), 304, 1.0));
d.layers.push(iso);
// A pass-through group with opacity around small layers.
let mut pt = Layer::group("pt", vec![small(305, 150, 10, BlendMode::Normal), small(306, 160, 20, BlendMode::Difference)]);
pt.opacity = 0.6;
d.layers.push(pt);
check(&mut g, &d, "small layers");
// The same over several compositor chunks.
let mut big = base_doc(1500, 1200);
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
let i = i as i32;
big.layers.push(small(400 + i as u32, 1010 + (i % 4) * 5, 1010 + (i / 4) * 5, mode));
}
check(&mut g, &big, "small layers across chunks");
}
fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer {
let mut l = Layer::raster(name, fmt);
let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32);
let ch = fmt.channels();
let mut data = Vec::with_capacity(rect.width() as usize * rect.height() as usize * ch);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let (fx, fy) = (x as f32 + 0.5 - cx, y as f32 + 0.5 - cy);
let d = (fx * fx + fy * fy).sqrt();
let disc = (r - d + 0.5).clamp(0.0, 1.0);
let hole = ((d - r * 0.3) + 0.5).clamp(0.0, 1.0);
let bar = ((r * 0.25 - fy.abs()) + 0.5).clamp(0.0, 1.0) * ((r + 10.0 - fx.abs()) * 0.7).clamp(0.0, 1.0);
let a = (disc * hole).max(bar * 0.7);
let rgba = [color[0] * (0.7 + 0.3 * (fx / r).abs()), color[1], color[2] * (0.8 + 0.2 * (fy / r)), a];
let px = photocraft_raster::from_rgba(&fmt, rgba);
data.extend_from_slice(&px);
}
}
l.surface_mut().unwrap().write_region(rect, &data);
l
}
fn fx_doc(w: u32, h: u32, depth: SampleType) -> Document {
let mut d = Document::new("fx", Size::new(w, h), ColorMode::Rgb, depth);
let fmt = d.pixel_format();
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, w, h), 7, 0.8));
d
}
fn contour() -> Contour {
Contour::Custom { name: "cove".into(), points: vec![Cp { input: 0.0, output: 0.1 }, Cp { input: 0.4, output: 0.8 }, Cp { input: 1.0, output: 0.6 }] }
}
fn shadow(blend: BlendMode, opacity: f32, angle: f32, distance: f32, size: f32, spread: f32) -> Shadow {
Shadow {
common: FxCommon::new(blend, opacity),
color: Color::rgb(0.1, 0.05, 0.3),
angle,
use_global_light: false,
distance,
spread,
size,
contour: Contour::Linear,
anti_alias: false,
noise: 0.0,
knocks_out: true,
}
}
fn glow(paint: FxPaint, technique: GlowTechnique, size: f32, spread: f32, source: GlowSource) -> Glow {
Glow {
common: FxCommon::new(BlendMode::Screen, 0.8),
paint,
technique,
spread,
size,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source,
}
}
fn gradient() -> Gradient {
Gradient {
stops: vec![(0.0, Color::rgb(1.0, 0.2, 0.0)), (0.5, Color::rgb(0.1, 0.9, 0.3)), (1.0, Color::rgb(0.2, 0.1, 1.0))],
opacity_stops: vec![(0.0, 1.0), (1.0, 0.4)],
style: GradientStyle::Linear,
angle: 30.0,
scale: 0.9,
reverse: false,
align: true,
offset: (0.1, -0.05),
}
}
fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel {
Bevel {
enabled: true,
style,
technique: BevelTechnique::Smooth,
depth: 1.3,
up,
size,
soften,
angle: 135.0,
altitude: 35.0,
use_global_light: true,
gloss_contour: Contour::Linear,
highlight: FxCommon::new(BlendMode::Screen, 0.8),
highlight_color: Color::rgb(1.0, 1.0, 0.9),
shadow: FxCommon::new(BlendMode::Multiply, 0.7),
shadow_color: Color::rgb(0.1, 0.0, 0.2),
contour: None,
texture: None,
}
}
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> StrokeFx {
StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint }
}
fn checker_pattern() -> Pattern {
let mut s = photocraft_raster::Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 6, 5), &[0.9, 0.2, 0.1, 1.0]);
s.fill_rect(Rect::new(0, 0, 3, 3), &[0.1, 0.3, 0.9, 0.6]);
s.fill_rect(Rect::new(3, 3, 6, 5), &[0.2, 0.8, 0.3, 1.0]);
Pattern::new("checker", s, 6, 5)
}
/// Every effect kind with several option combinations, one effect stack per case.
fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
let pat = checker_pattern();
let pat_paint = FxPaint::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0 };
let mut ds_contour = shadow(BlendMode::Normal, 0.8, 200.0, 4.0, 9.0, 0.35);
ds_contour.contour = contour();
ds_contour.knocks_out = false;
let mut ds_global = shadow(BlendMode::Multiply, 0.75, 0.0, 6.0, 7.0, 0.0);
ds_global.use_global_light = true;
let mut og_contour = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 9.0, 0.0, GlowSource::Edge);
og_contour.contour = contour();
let mut satin_inv = Satin {
common: FxCommon::new(BlendMode::Multiply, 0.6),
color: Color::rgb(0.3, 0.0, 0.4),
angle: 19.0,
distance: 7.0,
size: 8.0,
contour: contour(),
anti_alias: false,
invert: true,
};
let satin = Satin {
common: FxCommon::new(BlendMode::Overlay, 0.7),
color: Color::rgb(0.9, 0.4, 0.1),
angle: 60.0,
distance: 5.0,
size: 6.0,
contour: Contour::Linear,
anti_alias: false,
invert: false,
};
satin_inv.common.enabled = true;
let mut bevel_contour = bevel(BevelStyle::InnerBevel, true, 8.0, 3.0);
bevel_contour.gloss_contour = contour();
bevel_contour.use_global_light = false;
vec![
("drop shadow", vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0))]),
("drop shadow spread contour", vec![Effect::DropShadow(ds_contour)]),
("drop shadow hard", vec![Effect::DropShadow(shadow(BlendMode::Normal, 1.0, 45.0, 3.0, 0.0, 0.0))]),
("drop shadow global light", vec![Effect::DropShadow(ds_global)]),
("inner shadow", vec![Effect::InnerShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 4.0, 6.0, 0.0))]),
("inner shadow choke", vec![Effect::InnerShadow(shadow(BlendMode::Normal, 0.9, 300.0, 3.0, 7.0, 0.4))]),
("outer glow softer", vec![Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge))]),
("outer glow contour", vec![Effect::OuterGlow(og_contour)]),
("outer glow precise gradient", vec![Effect::OuterGlow(glow(FxPaint::Gradient(gradient()), GlowTechnique::Precise, 8.0, 0.25, GlowSource::Edge))]),
("inner glow softer edge", vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.9, 1.0, 0.8)), GlowTechnique::Softer, 7.0, 0.2, GlowSource::Edge))]),
(
"inner glow softer center",
vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.9, 1.0, 0.8)), GlowTechnique::Softer, 6.0, 0.0, GlowSource::Center))],
),
(
"inner glow precise edge",
vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.8)), GlowTechnique::Precise, 6.0, 0.3, GlowSource::Edge))],
),
("inner glow precise center", vec![Effect::InnerGlow(glow(pat_paint.clone(), GlowTechnique::Precise, 5.0, 0.0, GlowSource::Center))]),
("bevel inner", vec![Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 7.0, 2.0))]),
("bevel outer", vec![Effect::BevelEmboss(bevel(BevelStyle::OuterBevel, true, 6.0, 0.0))]),
("bevel emboss down", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, false, 8.0, 1.0))]),
("bevel pillow", vec![Effect::BevelEmboss(bevel(BevelStyle::PillowEmboss, true, 5.0, 4.0))]),
(
"bevel inner chisel hard",
vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::InnerBevel, true, 9.0, 0.0) })],
),
(
"bevel outer chisel soft",
vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselSoft, ..bevel(BevelStyle::OuterBevel, false, 7.5, 2.0) })],
),
(
"bevel pillow chisel hard",
vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })],
),
("bevel emboss smooth wide", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 21.0, 0.0))]),
("bevel stroke emboss", vec![Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss, true, 6.0, 0.0))]),
(
"bevel texture",
vec![Effect::BevelEmboss(Bevel {
texture: Some(photocraft_doc::BevelTexture {
name: "checker".into(),
id: String::new(),
scale: 1.4,
depth: -1.5,
invert: true,
link: true,
phase: (2.0, 1.0),
}),
..bevel(BevelStyle::InnerBevel, true, 7.0, 1.0)
})],
),
(
"bevel contour",
vec![Effect::BevelEmboss(Bevel {
contour: Some(photocraft_doc::BevelContour { contour: contour(), range: 0.6, anti_alias: false }),
..bevel(BevelStyle::Emboss, true, 9.0, 1.0)
})],
),
("bevel contour own light", vec![Effect::BevelEmboss(bevel_contour)]),
("satin", vec![Effect::Satin(satin)]),
("satin inverted contour", vec![Effect::Satin(satin_inv)]),
("stroke outside colour", vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.95, 0.85, 0.1))))]),
("stroke inside gradient", vec![Effect::Stroke(stroke(3.0, StrokePosition::Inside, FxPaint::Gradient(gradient())))]),
("stroke centre pattern", vec![Effect::Stroke(stroke(5.0, StrokePosition::Center, pat_paint.clone()))]),
("colour overlay", vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Multiply, 0.7), color: Color::rgb(0.2, 0.7, 0.9) }]),
(
"gradient overlay",
vec![Effect::GradientOverlay {
common: FxCommon::new(BlendMode::Normal, 0.8),
gradient: Gradient { style: GradientStyle::Radial, ..gradient() },
dither: false,
}],
),
(
"pattern overlay",
vec![Effect::PatternOverlay {
common: FxCommon::new(BlendMode::Normal, 0.9),
name: pat.name.clone(),
id: pat.id.clone(),
scale: 1.0,
angle: 0.0,
link: true,
phase: (2.0, 1.0),
}],
),
(
"pattern overlay scaled rotated",
vec![Effect::PatternOverlay {
common: FxCommon::new(BlendMode::Screen, 0.8),
name: pat.name.clone(),
id: pat.id.clone(),
scale: 1.7,
angle: 30.0,
link: false,
phase: (0.0, 0.0),
}],
),
(
"missing pattern",
vec![
Effect::PatternOverlay {
common: FxCommon::new(BlendMode::Normal, 1.0),
name: "nope".into(),
id: "nope".into(),
scale: 1.0,
angle: 0.0,
link: true,
phase: (0.0, 0.0),
},
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Pattern { name: "nope".into(), id: "nope".into(), scale: 1.0 })),
Effect::Stroke(stroke(6.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))),
],
),
(
"multiple instances",
vec![
Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
Effect::Stroke(stroke(5.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.1, 0.1)))),
Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 4.0, 5.0, 0.0)),
Effect::DropShadow(shadow(BlendMode::Normal, 0.5, 270.0, 8.0, 3.0, 0.0)),
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 0.3), color: Color::rgb(1.0, 0.0, 0.0) },
],
),
(
"full stack",
vec![
Effect::DropShadow(shadow(BlendMode::Multiply, 0.75, 120.0, 5.0, 6.0, 0.1)),
Effect::InnerShadow(shadow(BlendMode::Multiply, 0.5, 120.0, 3.0, 4.0, 0.0)),
Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(1.0, 1.0, 0.6)), GlowTechnique::Softer, 6.0, 0.0, GlowSource::Edge)),
Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(1.0, 1.0, 0.6)), GlowTechnique::Softer, 4.0, 0.0, GlowSource::Edge)),
Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 5.0, 1.0)),
Effect::Satin(Satin {
common: FxCommon::new(BlendMode::Multiply, 0.4),
color: Color::rgb(0.0, 0.0, 0.0),
angle: 19.0,
distance: 4.0,
size: 5.0,
contour: Contour::Linear,
anti_alias: false,
invert: false,
}),
Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 0.5), color: Color::rgb(0.9, 0.3, 0.2) },
Effect::GradientOverlay { common: FxCommon::new(BlendMode::Overlay, 0.4), gradient: gradient(), dither: false },
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))),
],
),
]
}
/// GPU vs CPU over the whole document: Err with the worst pixel if over the tolerance.
fn fx_diff(g: &mut Gpu, doc: &Document, what: &str) -> Result<photocraft_gpu::Stats, String> {
diff_rect(g, doc, doc.bounds(), what)
}
fn fx_check(g: &mut Gpu, doc: &Document, what: &str) -> photocraft_gpu::Stats {
fx_diff(g, doc, what).unwrap_or_else(|e| panic!("{e}"))
}
#[test]
fn layer_effects_every_kind() {
let Some(mut g) = gpu() else { return };
let mut failures = Vec::new();
for depth in [SampleType::U8, SampleType::U16] {
for (name, fx) in effect_cases() {
let mut d = fx_doc(96, 80, depth);
d.patterns.push(checker_pattern());
let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.9, 0.4, 0.2]);
l.effects.items = fx;
d.layers.push(l);
if let Err(e) = fx_diff(&mut g, &d, &format!("{name} {depth:?}")) {
failures.push(e);
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
#[test]
fn layer_effects_opacity_fill_blend_and_off_canvas() {
let Some(mut g) = gpu() else { return };
let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1;
for (opacity, fill, blend) in [
(0.7, 1.0, BlendMode::Normal),
(1.0, 0.0, BlendMode::Normal),
(0.6, 0.35, BlendMode::Multiply),
(1.0, 0.5, BlendMode::Screen),
(0.8, 1.0, BlendMode::Dissolve),
] {
let mut d = fx_doc(96, 80, SampleType::U8);
let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]);
l.effects.items = stack.clone();
l.opacity = opacity;
l.fill_opacity = fill;
l.blend = blend;
d.layers.push(l);
fx_check(&mut g, &d, &format!("opacity {opacity} fill {fill} {blend:?}"));
}
// Shapes partly off the canvas (their effects reach back in) and a masked effect layer.
let mut d = fx_doc(90, 70, SampleType::U8);
let mut l = blob("edge", d.pixel_format(), 4.0, 66.0, 18.0, [0.9, 0.9, 0.2]);
l.effects.items = stack.clone();
l.mask = Some(mask(Rect::new(0, 30, 50, 70), 77, 1.0));
d.layers.push(l);
fx_check(&mut g, &d, "off canvas + mask");
// Disabled master switch / disabled items are ignored.
let mut d = fx_doc(64, 64, SampleType::U8);
let mut l = blob("off", d.pixel_format(), 30.0, 30.0, 14.0, [0.9, 0.2, 0.2]);
l.effects.items = stack;
if let Effect::DropShadow(s) = &mut l.effects.items[0] {
s.common.enabled = false;
}
d.layers.push(l.clone());
fx_check(&mut g, &d, "disabled item");
d.layers[1].effects.enabled = false;
fx_check(&mut g, &d, "master switch off");
}
#[test]
fn layer_effects_on_groups_clipping_and_fills() {
let Some(mut g) = gpu() else { return };
let ds = Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 5.0, 6.0, 0.0));
let st = Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0))));
let bv = Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 5.0, 1.0));
// Effects on an isolated group and on a pass-through group (rendered isolated).
for blend in [BlendMode::Normal, BlendMode::PassThrough] {
let mut d = fx_doc(100, 80, SampleType::U8);
let a = blob("a", d.pixel_format(), 35.0, 35.0, 16.0, [0.9, 0.3, 0.2]);
let mut b = blob("b", d.pixel_format(), 62.0, 45.0, 14.0, [0.2, 0.3, 0.9]);
b.blend = BlendMode::Screen;
b.effects.items = vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(1.0, 1.0, 0.5)), GlowTechnique::Softer, 4.0, 0.0, GlowSource::Edge))];
let mut grp = Layer::group("g", vec![a, b]);
grp.blend = blend;
grp.opacity = 0.85;
grp.effects.items = vec![ds.clone(), st.clone()];
d.layers.push(grp);
fx_check(&mut g, &d, &format!("group {blend:?}"));
}
// An effect layer as a clipping base, and a clipped layer with effects.
let mut d = fx_doc(100, 80, SampleType::U8);
let mut base = blob("base", d.pixel_format(), 45.0, 40.0, 24.0, [0.3, 0.8, 0.4]);
base.effects.items = vec![ds.clone(), bv.clone()];
let mut c1 = noise_layer("c1", PixelFormat::RGBA8, Rect::new(10, 10, 60, 50), 91, 0.4);
c1.clipped = true;
c1.blend = BlendMode::Multiply;
let mut c2 = blob("c2", d.pixel_format(), 60.0, 50.0, 12.0, [0.9, 0.9, 0.1]);
c2.clipped = true;
c2.effects.items = vec![st.clone(), ds.clone()];
c2.opacity = 0.8;
d.layers.extend([base, c1, c2]);
fx_check(&mut g, &d, "clipping with effects");
// A solid fill layer with a mask and effects; a gradient fill with a stroke.
let mut d = fx_doc(90, 70, SampleType::U8);
let mut solid = Layer::new("solid", LayerContent::Fill(Fill::Solid(Color::rgb(0.2, 0.5, 0.8))));
let mut m = LayerMask::reveal_all();
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[0.0]);
m.surface.fill_rect(Rect::new(20, 15, 60, 50), &[1.0]);
solid.mask = Some(m);
solid.effects.items = vec![ds.clone(), st.clone(), bv];
d.layers.push(solid);
fx_check(&mut g, &d, "solid fill with effects");
}
#[test]
fn layer_effects_update_incrementally() {
let Some(mut g) = gpu() else { return };
let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1;
let mut d = fx_doc(700, 600, SampleType::U8);
let mut l = blob("fx", d.pixel_format(), 300.0, 280.0, 200.0, [0.9, 0.4, 0.2]);
l.effects.items = stack;
d.layers.push(l);
d.layers.push(noise_layer("plain", PixelFormat::RGBA8, Rect::new(500, 20, 650, 120), 5, 0.0));
let first = fx_check(&mut g, &d, "initial");
assert!(first.fx_programs > 0 && first.fx_shapes == 1);
// An unrelated edit (another layer's pixels, the effect layer's opacity and an effect colour)
// reuses every map.
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(520, 30, 560, 60), &[0.0, 1.0, 0.0, 1.0]);
d.layers[1].opacity = 0.8;
if let Effect::ColorOverlay { color, .. } = &mut d.layers[1].effects.items[6] {
*color = Color::rgb(0.1, 0.9, 0.9);
}
let s = fx_check(&mut g, &d, "unrelated edit");
assert_eq!((s.fx_shapes, s.fx_programs), (0, 0), "{s:?}");
// A dab on the effect layer recomputes a neighbourhood of it only.
d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(290, 270, 330, 300), &[0.1, 0.1, 0.9, 1.0]);
let s = fx_check(&mut g, &d, "dab on the effect layer");
assert_eq!(s.fx_shapes, 1);
assert!(s.fx_pixels < first.fx_pixels / 2, "partial {} vs full {}", s.fx_pixels, first.fx_pixels);
// Erasing to the layer's edge grows nothing; erasing all of it empties the effects.
d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(80, 60, 200, 200), &[0.0, 0.0, 0.0, 0.0]);
fx_check(&mut g, &d, "erase part");
// A setting change that keeps the effect reach rebuilds that effect only.
if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] {
s.spread = 0.3;
}
let s = fx_check(&mut g, &d, "drop shadow spread");
assert_eq!(s.fx_programs, 1, "{s:?}");
// Moving the layer by whole pixels moves its maps with it: nothing is rebuilt.
let moved = {
let src = d.layers[1].surface().unwrap();
let b = src.content_bounds();
let mut dst = photocraft_raster::Surface::new(src.format());
dst.write_region(Rect::new(b.x0 + 17, b.y0 - 9, b.x1 + 17, b.y1 - 9), &src.read_region(b));
dst
};
*d.layers[1].surface_mut().unwrap() = moved;
let s = fx_check(&mut g, &d, "moved");
assert_eq!(s.fx_programs, 0, "{s:?}");
// A change of shape with the move rebuilds.
d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(300, 300, 310, 310), &[1.0, 1.0, 1.0, 0.5]);
fx_check(&mut g, &d, "moved and painted");
}
#[test]
fn layer_effects_on_shape_layers() {
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, Subpath};
for alpha in [1.0, 0.55] {
let mut d = fx_doc(90, 70, SampleType::U8);
let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]);
let mut fill = Color::rgb(0.3, 0.6, 0.9);
fill.alpha = alpha;
let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
l.effects.items = vec![
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
Effect::Stroke(stroke(4.0, StrokePosition::Center, FxPaint::Gradient(gradient()))),
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0)),
Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 4.0, 1.0)),
];
d.layers.push(l);
fx_check(&mut g, &d, &format!("shape layer alpha {alpha}"));
}
}
#[test]
fn stroke_effects_on_filled_and_stroked_shapes() {
// compose::effect_outline (a fading gradient fill keeps its strokes along the path), several
// stroke instances with gradient frames, and a vector stroke above the interior effects with
// clipped layers and a mask.
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
for (fill_kind, vector_stroke, masked) in [(0, false, false), (1, false, true), (0, true, false), (1, true, true)] {
let mut d = fx_doc(90, 70, SampleType::U8);
let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]);
let mut clear = Color::rgb(0.9, 0.3, 0.1);
clear.alpha = 0.0;
let fill = if fill_kind == 0 {
Fill::Solid(Color::rgb(0.3, 0.6, 0.9))
} else {
Fill::gradient(vec![(0.0, Color::rgb(0.9, 0.3, 0.1)), (1.0, clear)], 20.0, 1.0, GradientStyle::Linear, false)
};
let stroke_v = vector_stroke.then(|| ShapeStroke {
width: 3.0,
align: StrokeAlign::Inside,
paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)),
..ShapeStroke::default()
});
let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
if masked {
l.mask = Some(mask(Rect::new(0, 0, 90, 70), 7, 0.6));
}
l.effects.items = vec![
Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
Effect::Stroke(stroke(5.0, StrokePosition::Outside, FxPaint::Gradient(gradient()))),
Effect::Stroke(stroke(3.0, StrokePosition::Inside, FxPaint::Gradient(gradient()))),
Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.7), color: Color::rgb(0.2, 0.2, 0.9) },
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0)),
];
d.layers.push(l);
let mut c = noise_layer("clip", PixelFormat::RGBA8, Rect::new(30, 0, 60, 70), 9, 0.5);
c.clipped = true;
d.layers.push(c);
fx_check(&mut g, &d, &format!("fill {fill_kind} vector stroke {vector_stroke} masked {masked}"));
}
}
#[test]
fn channel_restrictions() {
let Some(mut g) = gpu() else { return };
for mask_bits in [0b001u32, 0b010, 0b100, 0b101, 0b111] {
// Raster layer in a blend mode, an adjustment and a clipped layer.
let mut d = base_doc(64, 48);
let mut l = noise_layer("top", PixelFormat::RGBA8, Rect::new(4, 3, 60, 45), 2, 0.0);
l.blend = BlendMode::Multiply;
l.excluded_channels = mask_bits;
d.layers.push(l);
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
adj.excluded_channels = mask_bits.rotate_left(1) & 0b111;
d.layers.push(adj);
let mut c = noise_layer("clip", PixelFormat::RGBA8, Rect::new(10, 10, 50, 40), 3, 0.2);
c.clipped = true;
c.excluded_channels = mask_bits;
d.layers.push(c);
check(&mut g, &d, &format!("channels {mask_bits:03b}"));
// An effect layer (exterior and interior effects).
let mut d = fx_doc(96, 80, SampleType::U8);
let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.9, 0.4, 0.2]);
l.effects.items = vec![
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 6.0, 8.0, 0.1)),
Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(1.0, 1.0, 0.5)), GlowTechnique::Softer, 9.0, 0.0, GlowSource::Edge)),
];
l.excluded_channels = mask_bits;
d.layers.push(l);
fx_check(&mut g, &d, &format!("fx channels {mask_bits:03b}"));
}
// Grayscale: the single channel left out keeps the backdrop.
let mut d = Document::new("g", Size::new(32, 32), ColorMode::Grayscale, SampleType::U8);
d.layers.push(noise_layer("bg", PixelFormat::GRAYA8, Rect::new(0, 0, 32, 32), 9, 1.0));
let mut l = noise_layer("top", PixelFormat::GRAYA8, Rect::new(0, 0, 32, 32), 10, 1.0);
l.excluded_channels = 1;
d.layers.push(l);
check(&mut g, &d, "gray channels");
let flat = photocraft_compose::flatten(&d);
let bg = photocraft_compose::render_layer(&d.layers[0], d.bounds());
assert!(flat.px.iter().zip(&bg.px).all(|(a, b)| (a[0] - b[0]).abs() < 1e-6));
}
/// A type layer whose rendered pixels are `src`'s (blends with the text gamma).
fn as_text(src: Layer) -> Layer {
let t = photocraft_doc::TextLayer { cache: src.surface().cloned(), ..Default::default() };
let mut l = Layer::new(&src.name, LayerContent::Text(t));
l.blend = src.blend;
l.opacity = src.opacity;
l.effects = src.effects;
l
}
/// Held by tests that change the process-wide text gamma, and by tests with type layers that
/// run long enough to see such a change.
static TEXT_GAMMA: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn type_layers_blend_with_text_gamma() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut g) = gpu() else { return };
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Color] {
let mut d = base_doc(64, 48);
let mut l = as_text(noise_layer("text", PixelFormat::RGBA8, Rect::new(4, 3, 60, 45), 21, 0.0));
l.blend = mode;
l.opacity = 0.9;
d.layers.push(l);
// Clipped type layer.
let mut c = as_text(noise_layer("clip", PixelFormat::RGBA8, Rect::new(8, 8, 50, 40), 22, 0.0));
c.clipped = true;
c.blend = mode;
d.layers.push(c);
check(&mut g, &d, &format!("text {mode:?}"));
// Another gamma (Color Settings), and off.
for gamma in [1.8, 1.0] {
photocraft_compose::psblend::set_text_gamma(gamma);
check(&mut g, &d, &format!("text {mode:?} gamma {gamma}"));
}
photocraft_compose::psblend::set_text_gamma(photocraft_compose::psblend::TEXT_GAMMA);
}
// With effects (the merge of the layer onto its exterior effects).
let mut d = fx_doc(96, 80, SampleType::U8);
let mut l = as_text(blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.1, 0.6]));
l.effects.items = vec![
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(0.0, 0.2, 0.6) },
Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0)),
];
d.layers.push(l);
fx_check(&mut g, &d, "text fx");
// Emboss styles shade the composited layer (merge, shade, then the opacity mix), also as a
// clipped layer over an opaque base.
let mut e = fx_doc(96, 80, SampleType::U8);
let mut l = as_text(blob("emboss", e.pixel_format(), 46.0, 40.0, 20.0, [0.4, 0.1, 0.3]));
l.effects.items = vec![
Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 9.0, 0.0)),
Effect::BevelEmboss(bevel(BevelStyle::PillowEmboss, false, 5.0, 1.0)),
Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0)),
];
l.opacity = 0.7;
l.fill_opacity = 0.6;
e.layers.push(l.clone());
fx_check(&mut g, &e, "text emboss");
let mut c = l;
c.clipped = true;
c.blend = BlendMode::Multiply;
e.layers.push(c);
fx_check(&mut g, &e, "clipped text emboss");
// The gamma changes edge pixels against a linear mix.
let flat = photocraft_compose::flatten(&d);
let mut lin = d.clone();
let raster = {
let LayerContent::Text(t) = &lin.layers[1].content else { unreachable!() };
let mut r = Layer::new("r", LayerContent::Raster(t.cache.clone().unwrap()));
r.effects = lin.layers[1].effects.clone();
r
};
lin.layers[1] = raster;
let flat_lin = photocraft_compose::flatten(&lin);
assert!(flat.px.iter().zip(&flat_lin.px).any(|(a, b)| (a[0] - b[0]).abs() > 0.02));
}
#[test]
fn blend_mode_extremes() {
// Exact 0 / 1 channels on both sides (Color Burn / Dodge / Vivid Light / Hard Mix corners).
let Some(mut g) = gpu() else { return };
let vals = [0.0f32, 1.0, 0.5];
for depth in [SampleType::U8, SampleType::U16] {
for mode in BlendMode::LAYER_MODES {
let mut d = Document::new("x", Size::new(9, 9), ColorMode::Rgb, depth);
let fmt = d.pixel_format();
let mut bg = Layer::raster("bg", fmt);
let mut top = Layer::raster("top", fmt);
for (i, &b) in vals.iter().enumerate() {
for (j, &s) in vals.iter().enumerate() {
for k in 0..3 {
let (x, y) = ((i * 3 + k) as i32, j as i32 * 3);
let r = Rect::from_xywh(x, y, 1, 3);
bg.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&fmt, [b, b, b, 1.0]));
top.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&fmt, [s, s, s, [1.0, 0.6, 0.2][k]]));
}
}
}
top.blend = mode;
d.layers.push(bg);
d.layers.push(top);
check(&mut g, &d, &format!("extremes {mode:?} {depth:?}"));
}
}
}
#[test]
fn stroked_shapes_with_clipped_layers() {
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath};
for (blend, masked) in [(BlendMode::Normal, false), (BlendMode::Multiply, true)] {
let mut d = base_doc(80, 64);
let path = Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]);
let stroke = ShapeStroke { width: 5.0, paint: Fill::Solid(Color::rgb(0.9, 0.9, 0.1)), ..Default::default() };
let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), stroke: Some(stroke), live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
l.blend = blend;
l.opacity = 0.9;
if masked {
l.mask = Some(mask(Rect::new(0, 0, 80, 64), 41, 1.0));
}
let mut c = noise_layer("clip", PixelFormat::RGBA8, Rect::new(0, 0, 80, 64), 42, 0.5);
c.clipped = true;
c.blend = BlendMode::Screen;
d.layers.extend([l, c]);
let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds());
assert!(st.is_ok(), "planned on the GPU");
check(&mut g, &d, &format!("stroked shape + clipped {blend:?} masked {masked}"));
}
}
#[test]
fn artboards() {
let Some(mut g) = gpu() else { return };
use photocraft_doc::{Artboard, ArtboardBackground};
let backgrounds = [
ArtboardBackground::White,
ArtboardBackground::Transparent,
ArtboardBackground::Custom({
let mut c = Color::rgb(0.2, 0.7, 0.4);
c.alpha = 0.5;
c
}),
];
for bg in backgrounds {
for blend in [BlendMode::PassThrough, BlendMode::Normal, BlendMode::Multiply] {
let mut d = base_doc(72, 48);
let mut child = noise_layer("c", PixelFormat::RGBA8, Rect::new(0, 0, 72, 48), 51, 0.0);
child.blend = BlendMode::Screen;
let mut fx = blob("fx", PixelFormat::RGBA8, 34.0, 24.0, 12.0, [0.8, 0.3, 0.2]);
fx.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 6.0, 8.0, 0.0))];
let mut ab = Layer::group("Artboard", vec![child, fx]);
ab.blend = blend;
if let LayerContent::Group(gr) = &mut ab.content {
gr.artboard = Some(Artboard { rect: Rect::new(12, 6, 60, 40), background: bg, preset: String::new() });
}
d.layers.push(ab);
// A second, partly off-canvas board.
let mut ab2 = Layer::group("Artboard 2", vec![noise_layer("c2", PixelFormat::RGBA8, Rect::new(0, 0, 72, 48), 52, 0.3)]);
if let LayerContent::Group(gr) = &mut ab2.content {
gr.artboard = Some(Artboard { rect: Rect::new(50, 30, 90, 70), background: ArtboardBackground::Black, preset: String::new() });
}
d.layers.push(ab2);
fx_check(&mut g, &d, &format!("artboard {bg:?} {blend:?}"));
}
}
}
#[test]
fn pattern_fill_layers() {
let Some(mut g) = gpu() else { return };
let pat = checker_pattern();
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] {
let mut d = base_doc(70, 50);
d.patterns.push(pat.clone());
let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) };
let mut l = Layer::new("pat", LayerContent::Fill(fill));
l.opacity = 0.85;
l.blend = BlendMode::Overlay;
if masked {
l.mask = Some(mask(Rect::new(5, 5, 60, 45), 61, 0.0));
}
d.layers.push(l.clone());
// With effects, and a missing pattern (transparent).
let mut fx = l.clone();
fx.id = photocraft_doc::LayerId::fresh();
fx.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 3.0, 4.0, 0.0))];
fx.mask = Some(mask(Rect::new(20, 10, 50, 40), 62, 0.0));
d.layers.push(fx);
let mut missing = l;
missing.id = photocraft_doc::LayerId::fresh();
missing.content = LayerContent::Fill(Fill::Pattern { name: "nope".into(), id: "nope".into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) });
d.layers.push(missing);
fx_check(&mut g, &d, &format!("pattern fill link {link} scale {scale} angle {angle}"));
}
}
#[test]
fn lab_documents_mix_in_lab() {
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16] {
let mut d = Document::new("lab", Size::new(48, 40), ColorMode::Lab, depth);
let fmt = d.pixel_format();
d.layers.push(noise_layer("bg", fmt, Rect::new(0, 0, 48, 40), 81, 0.7));
let mut top = noise_layer("top", fmt, Rect::new(4, 4, 44, 36), 82, 0.0);
top.opacity = 0.8;
d.layers.push(top);
let mut m = noise_layer("mul", fmt, Rect::new(10, 2, 30, 38), 83, 0.3);
m.blend = BlendMode::Multiply;
d.layers.push(m);
let mut fx = blob("fx", fmt, 24.0, 20.0, 12.0, [0.7, 0.3, 0.2]);
fx.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Normal, 0.6, 120.0, 4.0, 5.0, 0.0))];
d.layers.push(fx);
fx_check(&mut g, &d, &format!("lab {depth:?}"));
}
// The mix really is in Lab: a half-transparent edge differs from an sRGB mix.
let mut d = Document::new("lab", Size::new(2, 1), ColorMode::Lab, SampleType::U8);
let fmt = d.pixel_format();
let mut a = Layer::raster("a", fmt);
a.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0]));
let mut b = Layer::raster("b", fmt);
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 0.0, 0.5]));
d.layers = vec![a, b];
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 Some((adapter, device, queue, _lock)) = any_device() else { return };
let mut comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba16Float) {
Ok(c) => c,
Err(e) => return eprintln!("skipping: {e}"),
};
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));
}
#[test]
fn unbuildable_pipelines_are_an_error_not_a_panic() {
// The app falls back to the CPU compositor on Err; a panic here would crash it (as FXC once
// did on D3D12). A depth format can't be a colour target, so its pipelines fail to build.
let Some((adapter, device, _, _lock)) = any_device() else { return };
let e = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Depth32Float).err().expect("depth target must fail");
assert!(e.0.contains("pipelines"), "{e}");
// The format the app picks builds (effect maps the adapter can't render are left out, not fatal).
let f = Compositor::preferred_acc_format(&adapter);
if adapter.get_texture_format_features(f).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) {
assert!(Compositor::try_new_with_format(&device, f).is_ok());
}
}
// ---- documents larger than the texture limit ------------------------------------------------
/// A document several pages wide under [`PAGED_LIMIT`]: pixel layers crossing page borders (one
/// masked, one off-canvas), a big effect layer whose region exceeds the limit (maps built per
/// page cell), a type layer with a shadow, an adjustment, a clipped group and a gradient fill.
fn big_doc() -> Document {
let (w, h) = (1100, 900);
let mut d = fx_doc(w, h, SampleType::U8);
// Right over the background: adjustment results round to 8 bits, which would turn sub-LSB
// float differences of blends beneath into whole steps.
let mut adj = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
adj.opacity = 0.6;
adj.mask = Some(mask(Rect::new(240, 0, 800, 520), 83, 0.0));
d.layers.push(adj);
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(-30, 200, 700, 820), 81, 0.0);
top.blend = BlendMode::Multiply;
top.mask = Some(mask(Rect::new(100, 240, 600, 700), 82, 1.0));
d.layers.push(top);
let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1;
let mut big = blob("big", d.pixel_format(), 520.0, 450.0, 330.0, [0.9, 0.4, 0.2]);
big.effects.items = stack;
big.effects.items.push(Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.6)), GlowTechnique::Softer, 40.0, 0.2, GlowSource::Edge)));
d.layers.push(big);
let mut text = as_text(blob("type", d.pixel_format(), 900.0, 150.0, 90.0, [0.1, 0.2, 0.9]));
text.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 135.0, 12.0, 20.0, 0.0))];
d.layers.push(text);
let a = noise_layer("ga", PixelFormat::RGBA8, Rect::new(200, 500, 1000, 880), 84, 0.6);
let mut c = noise_layer("gc", PixelFormat::RGBA8, Rect::new(0, 600, 1100, 700), 85, 0.0);
c.clipped = true;
c.blend = BlendMode::Screen;
let mut grp = Layer::group("g", vec![a, c]);
grp.opacity = 0.9;
d.layers.push(grp);
let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.6, Color::rgb(0.0, 1.0, 0.2)), (1.0, Color::rgb(0.1, 0.1, 0.9))];
let mut grad = Layer::new("grad", LayerContent::Fill(Fill::gradient(stops, 30.0, 0.8, GradientStyle::Radial, false)));
grad.opacity = 0.3;
grad.blend = BlendMode::Overlay;
d.layers.push(grad);
d
}
#[test]
fn documents_larger_than_the_texture_limit() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut g) = gpu() else { return };
let mut d = big_doc();
assert!(g.paged.supports(&d).is_ok());
// Every pass sees page borders, and effect maps and blurs crossing them.
check(&mut g, &d, "big document");
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
assert!(s.cells >= 20 && s.chunks == s.cells, "{s:?}");
// A viewport off the page grid covers only its cells.
let view = Rect::new(300, 330, 790, 610);
diff_rect(&mut g, &d, view, "viewport").unwrap_or_else(|e| panic!("{e}"));
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, view).unwrap().1;
assert_eq!(s.cells, 6, "{s:?}");
// A dab across a page corner on the effect layer, and an edit elsewhere.
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(500, 490, 530, 530), &[0.1, 0.1, 0.9, 1.0]);
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(250, 250, 270, 270), &[0.0, 0.0, 0.0, 0.0]);
check(&mut g, &d, "after dabs");
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
assert_eq!((s.tiles_uploaded, s.fx_shapes), (0, 0), "nothing changed since the last render: {s:?}");
}
#[test]
fn pages_are_evicted_under_the_budget() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut g) = gpu() else { return };
let mut d = big_doc();
check(&mut g, &d, "warm");
let all = g.paged.resident_bytes();
// A budget of one page: everything but the cell being drawn is evicted as the render goes.
let page = u64::from(g.paged.page_size()).pow(2) * 4;
g.paged.set_resident_budget(page);
for round in 0..3 {
let cpu = photocraft_compose::flatten(&d);
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap();
let worst = worst_diff(&cpu.px, &out);
assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1);
assert!(s.evicted > 0, "round {round}: {s:?}");
assert!(g.paged.resident_bytes() < all / 4, "{} of {all}", g.paged.resident_bytes());
// Evicted pages come back with the edits made meanwhile.
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(10 + round * 300, 300, 60 + round * 300, 340), &[0.9, 0.9, 0.1, 1.0]);
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(200 + round * 200, 420, 240 + round * 200, 470), &[0.0, 0.0, 0.0, 0.0]);
}
check(&mut g, &d, "after eviction");
}
#[test]
fn the_focused_view_stays_resident_after_a_full_refresh() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(g) = gpu() else { return };
let d = big_doc();
let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
comp.set_texture_limit(PAGED_LIMIT);
let view = Rect::new(520, 300, 760, 500);
photocraft_gpu::render_to_vec(&mut comp, &g.device, &g.queue, &d, view).unwrap();
// A budget holding just the view's pages: a full refresh evicts as it goes, and draws the
// view's cells last, so the next edit in the view uploads nothing.
// The working set of a region is what rendering it makes resident.
assert_eq!(comp.working_set(&d, view), comp.resident_bytes());
assert!(comp.working_set(&d, d.bounds()) > 4 * comp.resident_bytes());
comp.set_resident_budget(comp.resident_bytes());
assert!(comp.fits_budget(&d, view) && !comp.fits_budget(&d, d.bounds()));
comp.set_focus(Some(view));
for _ in 0..2 {
let s = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
assert!(s.evicted > 0, "{s:?}");
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, view).unwrap();
assert_eq!(s.tiles_uploaded, 0, "{s:?}");
let cpu = photocraft_compose::render(&d, view);
assert!(worst_diff(&cpu.px, &out).0 <= TOL);
}
}