photocraft/crates/gpu/tests/parity.rs

2325 lines
115 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.

//! 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};
/// One 8-bit step, plus a few f32 ulps: two results exactly one step apart can differ by a hair
/// more than `1.0 / 255.0` after premultiplying and subtracting (NVIDIA's shader arithmetic
/// lands on that side at the Hue/Saturation pixel in `adjustment_layers`).
const TOL: f32 = 1.0 / 255.0 + 4.0 * f32::EPSILON;
/// 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;
#[test]
fn hdr_adjustments_preserve_range_and_cpu_parity() {
let Some(mut g) = gpu() else { return };
let identity = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
let channel = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
for adj in [
Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 },
Adjustment::Exposure { exposure: 1.0, offset: 0.1, gamma: 1.3 },
Adjustment::Exposure { exposure: 0.8, offset: -0.02, gamma: 1.2 },
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
Adjustment::Levels {
master: channel.clone(),
per_channel: [channel.clone(), Default::default(), channel],
space: Default::default(),
black: Default::default(),
},
Adjustment::Curves {
master: identity.clone(),
per_channel: std::array::from_fn(|_| identity.clone()),
space: Default::default(),
black: Vec::new(),
pencil: false,
},
Adjustment::Curves {
master: Vec::new(),
per_channel: [Vec::new(), identity.clone(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 0.5 }]],
space: Default::default(),
black: Vec::new(),
pencil: false,
},
] {
let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32);
let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F);
layer.surface_mut().unwrap().write_region(Rect::new(0, 0, 3, 1), &[4.0, 2.0, 0.5, 1.0, -0.1, 3.0, 0.25, 0.75, 0.0, 0.0, 0.0, 1.0]);
d.layers = vec![layer, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
let cpu = photocraft_compose::flatten(&d);
assert!(cpu.px[0][0] > 1.0, "HDR reference unexpectedly clipped: {adj:?}");
check(&mut g, &d, &format!("HDR {adj:?}"));
}
}
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()?;
eprintln!("GPU parity backend: {:?}", adapter.get_info().backend);
// 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 stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() {
let Some(mut g) = gpu() else { return };
let mut uncached = Compositor::new(&g.device);
uncached.set_composite_cache_budget(0);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut doc = Document::new("reuse", Size::new(300, 70), ColorMode::Rgb, depth);
for seed in 1..=4 {
doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2));
}
check(&mut g, &doc, "populate prefix");
for (opacity, fill, blend, visible) in [
(0.4, 1.0, BlendMode::Normal, true),
(0.9, 0.5, BlendMode::Multiply, true),
(0.8, 0.7, BlendMode::Screen, false),
(1.0, 1.0, BlendMode::Normal, true),
] {
let top = doc.layers.last_mut().unwrap();
top.opacity = opacity;
top.fill_opacity = fill;
top.blend = blend;
top.visible = visible;
let stats = diff_rect(&mut g, &doc, doc.bounds(), "top properties").unwrap();
assert!(stats.prefix_hits > 0, "{depth:?}");
assert!(stats.prefix_passes_skipped > 0);
let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();
let cold = render_to_vec(&mut uncached, &g.device, &g.queue, &doc, doc.bounds()).unwrap();
assert_eq!(cached, cold, "reuse preserves the GPU accumulator exactly");
}
let old = doc.clone();
doc.layers[1].surface_mut().unwrap().write_pixel(5, 5, &[0.8, 0.2, 0.6, 0.7]);
assert_eq!(diff_rect(&mut g, &doc, doc.bounds(), "middle COW edit").unwrap().prefix_hits, 0);
doc.layers[1].opacity = 0.3;
assert!(diff_rect(&mut g, &doc, doc.bounds(), "repeat middle edit").unwrap().prefix_hits > 0);
check(&mut g, &old, "undo snapshot");
check(&mut g, &doc, "redo snapshot");
for opacity in [0.7, 0.5, 0.9] {
doc.layers[0].opacity = opacity;
assert_eq!(diff_rect(&mut g, &doc, doc.bounds(), "bottom edit").unwrap().prefix_hits, 0);
}
}
}
#[test]
fn root_checkpoints_keep_clipping_groups_and_adjustments_whole() {
let Some(mut g) = gpu() else { return };
for mode in [BlendMode::PassThrough, BlendMode::Normal] {
let mut doc = base_doc(300, 70);
let mut clipped = noise_layer("clip", PixelFormat::RGBA8, doc.bounds(), 5, 0.1);
clipped.clipped = true;
let mut group = Layer::group(
"group",
vec![noise_layer("child", PixelFormat::RGBA8, doc.bounds(), 4, 0.1), clipped, Layer::new("adjust", LayerContent::Adjustment(Adjustment::Invert))],
);
group.blend = mode;
group.opacity = 0.8;
group.mask = Some(mask(Rect::new(0, 0, 270, 60), 8, 1.0));
doc.layers.push(group);
let mut clip = noise_layer("outer clip", PixelFormat::RGBA8, doc.bounds(), 7, 0.1);
clip.clipped = true;
doc.layers.push(clip);
doc.layers.push(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 9, 0.1));
check(&mut g, &doc, "populate group prefix");
doc.layers[3].opacity = 0.4;
assert!(diff_rect(&mut g, &doc, doc.bounds(), "top above complete group").unwrap().prefix_hits > 0);
doc.layers[2].opacity = 0.3;
assert_eq!(diff_rect(&mut g, &doc, doc.bounds(), "clipping unit invalidates").unwrap().prefix_hits, 0);
if let LayerContent::Group(group) = &mut doc.layers[1].content {
group.children[0].surface_mut().unwrap().write_pixel(2, 2, &[1.0, 0.0, 0.0, 0.5]);
group.children[2].content = LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.2, offset: 0.0, gamma: 1.0 });
}
check(&mut g, &doc, "nested pixels and adjustment changed");
doc.layers[1].mask.as_mut().unwrap().density = 0.3;
check(&mut g, &doc, "group mask changed");
doc.layers[3].opacity = 0.9;
check(&mut g, &doc, "reuse below changed group");
}
}
/// Many disjoint partial damages (a brush stroke paints a new rectangle every frame) must
/// admit whole grid chunks, not one entry per exact damage rectangle: a second series of
/// strokes inside the same chunks leaves the cache bytes unchanged, a full-canvas refresh
/// still finds its chunks, and a top-layer edit afterwards reuses them (#1169 review).
#[test]
fn disjoint_partial_damages_admit_whole_chunks_not_damage_rects() {
let Some(mut g) = gpu() else { return };
let mut doc = base_doc(600, 300);
doc.layers.push(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2));
doc.layers.push(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 3, 0.2));
check(&mut g, &doc, "warm");
let strokes = |g: &mut Gpu, doc: &Document, pass: i32| {
for i in 0..24u32 {
let x = 40 + ((i + pass as u32 * 13) % 8) * 60;
let y = 30 + (i / 8) * 60;
diff_rect(g, doc, Rect::new(x as i32, y as i32, x as i32 + 40, y as i32 + 30), "stroke").unwrap();
}
};
for pass in 0..2 {
doc.layers[2].opacity = 0.3 + pass as f32 * 0.1;
strokes(&mut g, &doc, pass);
}
// The same grid chunks hold every stroke: no entry-per-rectangle growth between passes.
let bytes = g.comp.composite_cache_bytes();
assert!(bytes > 0);
for pass in 2..4 {
doc.layers[2].opacity = 0.3 + pass as f32 * 0.1;
strokes(&mut g, &doc, pass);
}
assert_eq!(g.comp.composite_cache_bytes(), bytes, "partial damages must not add entries");
// A full-canvas render still populates the whole chunks, and the top edit afterwards hits.
check(&mut g, &doc, "full refresh after strokes");
doc.layers[2].opacity = 0.6;
let stats = diff_rect(&mut g, &doc, doc.bounds(), "top edit after strokes").unwrap();
assert!(stats.prefix_hits > 0, "{stats:?}");
// A repeated partial rectangle keeps hitting its whole chunk.
let at = Rect::new(40, 30, 80, 60);
let stats = diff_rect(&mut g, &doc, at, "same partial rectangle").unwrap();
assert!(stats.prefix_hits > 0, "{stats:?}");
}
#[test]
fn prefix_partial_damage_context_occlusion_and_budget_are_conservative() {
let Some(mut g) = gpu() else { return };
let mut doc = base_doc(600, 300);
doc.layers.push(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2));
doc.layers.push(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 3, 0.2));
check(&mut g, &doc, "full prefix");
// A partial rectangle inside an already-covered whole chunk is served as a sub-rect copy —
// after a full render every in-bounds rectangle is covered, which is the point of whole
// chunk admission. Outside the document nothing renders and nothing is admitted.
let region = Rect::new(10, 10, 50, 45);
doc.layers[2].opacity = 0.4;
assert!(diff_rect(&mut g, &doc, region, "partial rectangle in a covered chunk").unwrap().prefix_hits > 0);
let outside = Rect::new(10, 310, 50, 345);
assert_eq!(diff_rect(&mut g, &doc, outside, "outside the document").unwrap().prefix_hits, 0);
doc.layers[2].opacity = 0.7;
assert!(diff_rect(&mut g, &doc, region, "same partial rectangle").unwrap().prefix_hits > 0);
doc.layers[0].surface_mut().unwrap().write_pixel(550, 270, &[1.0, 0.0, 1.0, 0.9]);
check(&mut g, &doc, "offscreen lower edit");
diff_rect(&mut g, &doc, Rect::new(500, 250, 580, 290), "disjoint damage after invalidation").unwrap();
doc.global_light.angle += 15.0;
assert_eq!(diff_rect(&mut g, &doc, doc.bounds(), "global context").unwrap().prefix_hits, 0);
doc.layers.push(Layer::new("opaque cover", LayerContent::Fill(Fill::Solid(Color::WHITE))));
check(&mut g, &doc, "occlusion starts above old prefix");
doc.layers.last_mut().unwrap().opacity = 0.5;
check(&mut g, &doc, "occlusion removed");
doc.layers.swap(0, 1);
check(&mut g, &doc, "reorder");
doc.layers.remove(1);
check(&mut g, &doc, "delete");
doc.size = Size::new(300, 200);
check(&mut g, &doc, "resize");
let budget = g.comp.memory_budget();
g.comp.set_composite_cache_budget(1024);
assert!(g.comp.composite_cache_bytes() <= 1024);
assert_eq!(g.comp.memory_budget(), budget);
check(&mut g, &doc, "tiny cache");
assert!(g.comp.composite_cache_bytes() <= 1024);
g.comp.set_composite_cache_budget(0);
assert_eq!(diff_rect(&mut g, &doc, doc.bounds(), "disabled cache").unwrap().prefix_hits, 0);
assert_eq!(g.comp.composite_cache_bytes(), 0);
g.comp.set_memory_budget(1 << 30);
assert_eq!(g.comp.memory_budget(), 1 << 30);
check(&mut g, &doc, "restore budget");
assert!(g.comp.composite_cache_bytes() > 0);
g.comp.forget_doc(doc.id);
assert_eq!(g.comp.composite_cache_bytes(), 0);
check(&mut g, &doc, "after close/reopen");
g.comp.set_texture_limit(256);
assert_eq!(g.comp.composite_cache_bytes(), 0);
check(&mut g, &doc, "after paging change");
g.comp.set_composite_cache_budget(1 << 20);
check(&mut g, &doc, "bounded admission");
assert!(g.comp.composite_cache_bytes() <= 1 << 20);
assert!(diff_rect(&mut g, &doc, doc.bounds(), "bounded cache keeps its hits").unwrap().prefix_hits > 0);
doc.layers[0].blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]);
assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).is_err());
assert_eq!(g.comp.composite_cache_bytes(), 0);
}
#[test]
fn discarded_encode_neither_consumes_nor_populates_prefixes() {
let Some(mut g) = gpu() else { return };
// Analytic fills isolate checkpoint submission from the pre-existing input-page staging.
let mut doc = Document::new("encoder", Size::new(64, 48), ColorMode::Rgb, SampleType::U8);
doc.layers.push(Layer::new("lower", LayerContent::Fill(Fill::Solid(Color::rgba(0.4, 0.1, 0.7, 0.8)))));
doc.layers.push(Layer::new("upper", LayerContent::Fill(Fill::Solid(Color::rgba(0.2, 0.6, 0.3, 0.5)))));
let mut encoder = g.device.create_command_encoder(&Default::default());
let stats = g.comp.encode(&g.device, &g.queue, &mut encoder, &doc, doc.bounds(), &mut |_, _| {}).unwrap();
assert_eq!(stats.prefix_hits, 0);
assert_eq!(g.comp.composite_cache_bytes(), 0);
drop(encoder);
check(&mut g, &doc, "render after discarded cold encoder");
doc.layers[1].opacity = 0.4;
let mut encoder = g.device.create_command_encoder(&Default::default());
let stats = g.comp.encode(&g.device, &g.queue, &mut encoder, &doc, doc.bounds(), &mut |_, _| {}).unwrap();
assert_eq!(stats.prefix_hits, 0);
drop(encoder);
assert!(diff_rect(&mut g, &doc, doc.bounds(), "render after discarded warm encoder").unwrap().prefix_hits > 0);
}
#[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"));
}
}
/// Photoshop's special eight apply Fill inside the blend (psblend::composite_fill): the GPU's
/// blend pass takes Fill separately and must match the CPU at low and middle Fill, with Opacity,
/// over transparency and over an opaque backdrop.
#[test]
fn special_eight_fill_matches_cpu() {
let Some(mut g) = gpu() else { return };
let special = [
BlendMode::ColorBurn,
BlendMode::LinearBurn,
BlendMode::ColorDodge,
BlendMode::LinearDodge,
BlendMode::VividLight,
BlendMode::LinearLight,
BlendMode::HardMix,
BlendMode::Difference,
];
for mode in special {
for fill in [0.3, 0.6] {
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.7;
l.fill_opacity = fill;
d.layers.push(l);
check(&mut g, &d, &format!("{mode:?} fill {fill}"));
d.layers[0] = noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 5, 1.0);
check(&mut g, &d, &format!("{mode:?} fill {fill} opaque"));
}
}
}
#[test]
fn xor_rounding_on_half_float_targets() {
let Some((_adapter, device, queue, _lock)) = any_device() else { return };
let Ok(mut comp) = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba16Float) else { return };
let mut doc = Document::new("XOR rounding", Size::new(2, 2), ColorMode::Rgb, SampleType::U8);
for (value, opacity, blend) in [(127, 1.0, BlendMode::Normal), (128, 127.0 / 255.0, BlendMode::Normal), (64, 1.0, BlendMode::Xor)] {
let mut layer = Layer::raster("pixel", PixelFormat::RGBA8);
let channel = value as f32 / 255.0;
layer.surface_mut().unwrap().fill_rect(doc.bounds(), &[channel, channel, channel, 1.0]);
layer.opacity = opacity;
layer.blend = blend;
doc.layers.push(layer);
}
let cpu = photocraft_compose::flatten(&doc);
assert!((cpu.px[0][0] - 63.0 / 255.0).abs() < 1e-6);
let gpu = render_to_vec(&mut comp, &device, &queue, &doc, doc.bounds()).unwrap();
assert!(worst_diff(&cpu.px, &gpu).0 <= TOL, "CPU {:?}, GPU {:?}", cpu.px[0], gpu[0]);
}
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(),
pencil: false,
},
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: 30.0, saturation: 0.0, lightness: -15.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::Vibrance { vibrance: -60.0, saturation: 0.0 },
Adjustment::Vibrance { vibrance: 0.0, saturation: -100.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:?}"));
// Photo Filter: SetLum on the encoded values in 16-bit, luminance-normalised in 32-bit.
let pf = Adjustment::PhotoFilter { color: [0.93, 0.54, 0.0], density: 0.6, preserve_luminosity: true };
d.layers.push(Layer::new("pf", LayerContent::Adjustment(pf)));
check(&mut g, &d, &format!("photo filter {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 hard_stop_gradients_match_cpu_or_fall_back() {
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = Document::new("hard stops", Size::new(8, 2), ColorMode::Rgb, depth);
let mut fill =
Fill::gradient(vec![(0.0, Color::BLACK), (0.5, Color::WHITE), (0.5, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false);
for opacity in [false, true] {
if opacity && let Fill::Gradient { stops, opacity_stops, .. } = &mut fill {
*stops = vec![(0.0, Color::WHITE), (1.0, Color::WHITE)];
*opacity_stops = vec![(0.0, 0.0), (0.5, 1.0), (0.5, 0.0), (1.0, 1.0)];
}
d.layers.clear();
d.layers.push(Layer::new("gradient", LayerContent::Fill(fill.clone())));
let cpu = photocraft_compose::render(&d, d.bounds());
assert_eq!(cpu.px[4], [1.0; 4]);
let out = match render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()) {
Ok(out) => out,
Err(e) => {
assert!(e.0.contains("coincident gradient stops"), "{e}");
photocraft_compose::render(&d, d.bounds()).px
}
};
let worst = worst_diff(&cpu.px, &out);
assert!(worst.0 <= TOL, "{depth:?}: diff {}/255, cpu {:?}, gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]);
assert!(g.comp.supports(&d).is_err(), "hard stops must request the canvas's CPU fallback");
}
// The ordinary two-stop control must still render on the GPU.
d.layers[0].content = LayerContent::Fill(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false));
check(&mut g, &d, &format!("linear gradient control {depth:?}"));
}
}
#[test]
fn hard_stop_fill_fallback_respects_caches_and_visibility() {
use photocraft_doc::FillCache;
use photocraft_raster::Surface;
for opacity in [false, true] {
// Non-adjacent duplicates must be caught too: the CPU sorts fill stops.
let mut fill = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false);
if let Fill::Gradient { stops, opacity_stops, .. } = &mut fill {
if opacity {
*opacity_stops = vec![(0.5, 1.0), (0.0, 0.0), (0.5, 0.0), (1.0, 1.0)];
} else {
*stops = vec![(0.5, Color::WHITE), (0.0, Color::BLACK), (0.5, Color::BLACK), (1.0, Color::WHITE)];
}
}
let mut d = Document::new("hard stops", Size::new(8, 2), ColorMode::Rgb, SampleType::U8);
d.layers.push(Layer::new("gradient", LayerContent::Fill(fill.clone())));
assert_gradient_fallback(&d);
d.layers[0].visible = false;
assert!(photocraft_gpu::plan(&d).is_ok(), "hidden fill is not sampled");
d.layers[0].visible = true;
d.layers[0].fill_cache = Some(FillCache { fill: fill.clone(), surface: Surface::with_default(d.pixel_format(), &[1.0; 4]) });
assert!(photocraft_gpu::plan(&d).is_ok(), "matching fill cache is sampled instead of the ramp");
if let LayerContent::Fill(Fill::Gradient { reverse, .. }) = &mut d.layers[0].content {
*reverse = true;
}
assert_gradient_fallback(&d);
// Group and clipping routes must propagate Unsupported to the canvas too.
d.layers[0] = Layer::group("group", vec![Layer::new("gradient", LayerContent::Fill(fill.clone()))]);
assert_gradient_fallback(&d);
let mut clipped = Layer::new("clipped", LayerContent::Fill(fill));
clipped.clipped = true;
d.layers = vec![Layer::new("base", LayerContent::Fill(Fill::Solid(Color::WHITE))), clipped];
assert_gradient_fallback(&d);
d.layers.push(Layer::new("cover", LayerContent::Fill(Fill::Solid(Color::WHITE))));
assert!(photocraft_gpu::plan(&d).is_ok(), "occluded gradients are not sampled");
}
}
fn assert_gradient_fallback(d: &Document) {
let e = photocraft_gpu::plan(d).unwrap_err();
assert!(e.0.contains("coincident gradient stops"), "{e}");
}
#[test]
fn hard_stop_effects_and_gradient_maps_request_cpu() {
let mut d = Document::with_background("hard stops", Size::new(8, 2), ColorMode::Rgb, SampleType::U8, Color::WHITE);
for opacity in [false, true] {
for position in [0.0, 0.5, 1.0] {
let mut gradient = Gradient::default();
if opacity {
gradient.opacity_stops = vec![(position, 1.0), (position, 0.0)];
} else {
gradient.stops = vec![(position, Color::WHITE), (position, Color::BLACK)];
}
let paint = FxPaint::Gradient(gradient.clone());
let effects = [
Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false },
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 1.0, position: StrokePosition::Inside, paint: paint.clone() }),
Effect::InnerGlow(glow(paint.clone(), GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)),
Effect::OuterGlow(glow(paint, GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)),
];
for effect in effects {
d.layers[0].effects.items = vec![effect];
assert_gradient_fallback(&d);
d.layers[0].effects.enabled = false;
assert!(photocraft_gpu::plan(&d).is_ok(), "disabled effects are not sampled");
d.layers[0].effects.enabled = true;
d.layers[0].effects.items[0].set_enabled(false);
assert!(photocraft_gpu::plan(&d).is_ok(), "disabled effect is not sampled");
}
}
}
d.layers[0].effects.items.clear();
d.layers.push(Layer::new(
"map",
LayerContent::Adjustment(Adjustment::GradientMap {
stops: vec![(0.0, [0.0; 3]), (0.5, [1.0; 3]), (0.5, [0.0; 3]), (1.0, [1.0; 3])],
reverse: false,
dither: false,
}),
));
assert_gradient_fallback(&d);
d.layers[1].clipped = true;
assert_gradient_fallback(&d);
d.layers[1].visible = false;
assert!(photocraft_gpu::plan(&d).is_ok());
}
#[test]
fn clipped_layers_take_the_base_shape_below_full_fill() {
// compose::clips_by_shape: a base below 100 % fill keeps its clipped layers at full
// strength inside its alpha × mask; the group blends at the base's mode and opacity. A
// clipped Dissolve layer dissolves (#3052).
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for (base_blend, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true), (BlendMode::Screen, 0.9, false)] {
for fill in [1.0, 0.5, 0.0] {
let mut d = Document::new("t", Size::new(64, 48), ColorMode::Rgb, depth);
d.layers.push(noise_layer("bg", d.pixel_format(), d.bounds(), 1, 0.3));
let mut base = noise_layer("base", d.pixel_format(), Rect::new(8, 6, 50, 40), 16, 0.0);
base.blend = base_blend;
base.opacity = opacity;
base.fill_opacity = fill;
if masked {
base.mask = Some(mask(Rect::new(0, 0, 64, 48), 22, 1.0));
}
let mut c1 = noise_layer("multiply", d.pixel_format(), Rect::new(0, 0, 40, 48), 17, 0.4);
c1.clipped = true;
c1.blend = BlendMode::Multiply;
c1.opacity = 0.8;
let mut c2 = noise_layer("dissolve", d.pixel_format(), Rect::new(20, 0, 64, 48), 18, 0.3);
c2.clipped = true;
c2.blend = BlendMode::Dissolve;
c2.opacity = 0.6;
let mut c3 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
c3.clipped = true;
c3.opacity = 0.5;
d.layers.extend([base, c1, c2, c3]);
check(&mut g, &d, &format!("{depth:?} base {base_blend:?} opacity {opacity} fill {fill} masked {masked}"));
// With effects: the base's alpha stays the effect shape, the group's own alpha
// its per-pixel fill.
d.layers[1].effects.items = vec![
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.9, 0.8, 0.1)))),
Effect::InnerShadow(shadow(BlendMode::Multiply, 0.6, 120.0, 3.0, 4.0, 0.0)),
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 90.0, 4.0, 5.0, 0.0)),
];
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!("{depth:?} fx base {base_blend:?} opacity {opacity} fill {fill} 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");
// Stacked dithered gradients, the top one in Color Dodge (#2755): the dither is rounded to
// the document's levels on both sides, so the dodge doesn't blow it up into speckle.
let mut d = base_doc(64, 48);
for (name, stops, angle, blend) in [
("under", vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0, BlendMode::Normal),
("dodge", vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0, BlendMode::ColorDodge),
] {
let mut f = Fill::gradient(stops, angle, 1.0, GradientStyle::Linear, false);
if let Fill::Gradient { dither, .. } = &mut f {
*dither = true;
}
let mut l = Layer::new(name, LayerContent::Fill(f));
l.blend = blend;
d.layers.push(l);
}
check(&mut g, &d, "dithered gradients under Color Dodge");
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");
}
/// A duplicate shares its source's tiles: its texture is a GPU copy of the source's, not an
/// upload. Tiles written afterwards, to the copy or to the source in the same refresh, must not be
/// lost to the copy or leak through it (queued writes land before the encoder's copy).
#[test]
fn duplicates_copy_their_source_texture() {
let Some(mut g) = gpu() else { return };
let mut d = base_doc(600, 300);
let mut src = noise_layer("src", PixelFormat::RGBA8, Rect::new(0, 0, 600, 300), 71, 0.0);
src.opacity = 0.7;
d.layers.push(src);
check(&mut g, &d, "source");
// A duplicate with no other change: nothing uploads.
let mut dup = d.layers[1].duplicate();
dup.blend = BlendMode::Difference;
d.layers.push(dup);
let stats = diff_rect(&mut g, &d, d.bounds(), "duplicate").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(stats.tiles_uploaded, 0, "{stats:?}");
assert!(stats.tiles_shared > 0, "{stats:?}");
// Another duplicate, then one tile painted on it and one on its source, in the same refresh.
let mut dup2 = d.layers[1].duplicate();
dup2.blend = BlendMode::Screen;
dup2.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
d.layers.push(dup2);
d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(300, 100, 310, 110), &[0.0, 0.0, 1.0, 1.0]);
check(&mut g, &d, "duplicate and source painted");
// And a tile removed from a duplicate: it reads as transparent again.
let mut dup3 = d.layers[1].duplicate();
*dup3.surface_mut().unwrap() = {
let mut s = photocraft_raster::Surface::new(PixelFormat::RGBA8);
s.put_tiles(d.layers[1].surface().unwrap().tiles().skip(1).map(|(c, t)| (*c, t.clone())));
s
};
d.layers.push(dup3);
check(&mut g, &d, "duplicate missing a tile");
}
/// #2422: a mask whose default pixel changes without touching its tiles (Invert on a sparse mask)
/// must re-upload the absent tiles between its stored ones, not keep the old default on the GPU.
#[test]
fn changed_mask_default_updates_absent_tiles() {
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.5));
// Tiles in opposite corners: the resident texture spans the absent tiles between them.
let corners = [Rect::new(0, 0, 70, 70), Rect::new(530, 230, 600, 300)];
let data = vec![0.5f32; 70 * 70];
for default in [0.0, 1.0] {
let mut m = LayerMask::reveal_all();
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[default]);
for r in corners {
m.surface.write_region(r, &data);
}
d.layers[1].mask = Some(m);
check(&mut g, &d, &format!("mask default {default}"));
}
}
/// #1774: tiles shared across coordinates (a solid fill, a cleared area, the default pixel of a
/// mask) upload once and are copied on the GPU, at 8 and 16 bits, in RGB and grayscale, on a new
/// layer page and on an incremental update.
#[test]
fn shared_tiles_upload_once_and_copy() {
let Some(mut g) = gpu() else { return };
let (w, h) = (1100, 700);
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAYA8 }] {
let mut d = base_doc(w, h);
let mut l = Layer::raster("fill", fmt);
l.surface_mut().unwrap().fill_rect(Rect::new(30, 40, 90, 70), &[0.3, 0.6, 0.2, 0.9][..fmt.channels()]);
let mut m = LayerMask::reveal_all();
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[1.0]);
l.mask = Some(m);
d.layers.push(l);
check(&mut g, &d, &format!("{fmt:?} small"));
// Select All + Fill: the page grows (a new page), every full tile shares one.
let s = d.layers[1].surface_mut().unwrap();
s.fill_rect(Rect::new(0, 0, w as i32, h as i32), &[0.7, 0.2, 0.5, 0.8][..fmt.channels()]);
check(&mut g, &d, &format!("{fmt:?} filled"));
// Fill again (same page, incremental), then paint one tile and mask most of it.
let s = d.layers[1].surface_mut().unwrap();
s.fill_rect(Rect::new(0, 0, w as i32, h as i32), &[0.1, 0.9, 0.4, 0.6][..fmt.channels()]);
s.fill_rect(Rect::new(300, 300, 340, 320), &[1.0, 0.0, 0.0, 1.0][..fmt.channels()]);
let m = &mut d.layers[1].mask.as_mut().unwrap().surface;
m.fill_rect(Rect::new(0, 0, w as i32, h as i32), &[0.25]);
check(&mut g, &d, &format!("{fmt:?} refilled"));
// Clear the mask back to its (non-zero) default: its tiles go away.
d.layers[1].mask.as_mut().unwrap().surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[1.0]);
check(&mut g, &d, &format!("{fmt:?} mask cleared"));
// Clear the layer: every tile goes away and reads as transparent again.
*d.layers[1].surface_mut().unwrap() = photocraft_raster::Surface::new(fmt);
check(&mut g, &d, &format!("{fmt:?} cleared"));
}
}
/// Painting past a layer's tiles grows its resident page: the texels it already holds are kept
/// (only the new tiles upload, where re-uploading every tile made some brush dabs on large
/// 32-bit documents several times slower), in every direction, at 8 and 32 bits, under a mask
/// whose absent tiles read as a non-zero default; going back to an earlier, smaller state (undo)
/// shrinks it. Pixels match a compositor that uploads everything afresh, exactly.
#[test]
fn growing_layer_keeps_its_resident_tiles() {
let Some(mut g) = gpu() else { return };
let (w, h) = (1100, 700);
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] {
let mut d = base_doc(w, h);
let mut l = Layer::raster("paint", fmt);
l.surface_mut().unwrap().fill_rect(Rect::new(500, 300, 540, 340), &[0.3, 0.6, 0.2, 0.9][..fmt.channels()]);
let mut m = LayerMask::reveal_all();
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[0.75]);
m.surface.fill_rect(Rect::new(510, 310, 520, 320), &[0.25]);
l.mask = Some(m);
d.layers.push(l);
check(&mut g, &d, &format!("{fmt:?} initial"));
let small = d.clone();
// Dabs right (over a stored tile too, so it changes as the page grows), down, up-left
// (the page grows towards the origin), inside, and far out.
for (i, r) in [(480, 320, 760, 380), (520, 560, 580, 620), (60, 40, 120, 100), (505, 305, 535, 335), (1040, 640, 1100, 700)].into_iter().enumerate() {
let dab = Rect::new(r.0, r.1, r.2, r.3);
d.layers[1].surface_mut().unwrap().fill_rect(dab, &[0.9, 0.1, 0.3, 0.7][..fmt.channels()]);
if i == 2 {
d.layers[1].mask.as_mut().unwrap().surface.fill_rect(dab, &[0.5]);
}
let what = format!("{fmt:?} dab {i}");
let s = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}"));
assert!(s.tiles_uploaded <= 2, "{what}: only the dab's tiles upload: {s:?}");
let mut fresh = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
let want = render_to_vec(&mut fresh, &g.device, &g.queue, &d, d.bounds()).unwrap();
assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).unwrap() == want, "{what}: same pixels as a fresh upload");
}
// Undo back to the small layer: the pages shrink and show its pixels again.
check(&mut g, &small, &format!("{fmt:?} undone"));
}
}
// ---- 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().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().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");
}
#[test]
fn many_layers_submit_inside_one_chunk() {
// P12: a refresh recorded every pass of a page cell into one command buffer, and with a few
// hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is
// invalid"). 700 layers need well over 1000 passes in the document's only chunk.
let Some(mut g) = gpu() else { return };
let mut d = base_doc(64, 48);
let modes: Vec<BlendMode> = BlendMode::layer_modes().collect();
for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() {
let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0);
l.blend = mode;
l.opacity = 0.9;
d.layers.push(l);
}
let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(stats.chunks, 1);
assert!(stats.flushes > 0, "{stats:?}");
}
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 }],
corners: vec![],
}
}
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))]),
(
"outer glow softer gradient",
vec![Effect::OuterGlow(Glow { range: 0.4, ..glow(FxPaint::Gradient(gradient()), GlowTechnique::Softer, 10.0, 0.0, GlowSource::Edge) })],
),
("inner glow softer gradient", vec![Effect::InnerGlow(glow(FxPaint::Gradient(gradient()), GlowTechnique::Softer, 7.0, 0.1, 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:?}"));
}
// Fill below 100 % with an Outside stroke: the interior stays clear of the stroke (the
// "outline only" look of Fill 0 %).
for fill in [0.0, 0.5] {
let mut d = fx_doc(80, 80, SampleType::U8);
let mut l = blob("ring", d.pixel_format(), 40.0, 40.0, 20.0, [0.2, 0.6, 0.9]);
l.effects.items = vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.95, 0.85, 0.1))))];
l.fill_opacity = fill;
d.layers.push(l);
fx_check(&mut g, &d, &format!("outside stroke at fill {fill}"));
}
// 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:?}"));
}
// A child's stroke reaching past the group's own effect reach (#3274).
let mut d = fx_doc(100, 80, SampleType::U8);
let mut a = blob("a", d.pixel_format(), 45.0, 40.0, 16.0, [0.6, 0.6, 0.6]);
a.effects.items = vec![Effect::Stroke(stroke(8.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0))))];
let mut grp = Layer::group("g", vec![a]);
grp.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 3.0, 2.0, 0.0))];
d.layers.push(grp);
fx_check(&mut g, &d, "group effects around a child's stroke");
// 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_move_with_a_layer_overhanging_the_canvas() {
// A layer the size of the canvas with a drop shadow and a stroke, dragged with the Move tool
// (#761): its pixels overhang the edge, yet each move reuses the maps instead of rebuilding
// them, and the result still matches the CPU.
let Some(mut g) = gpu() else { return };
let mut d = fx_doc(160, 120, SampleType::U8);
let mut l = noise_layer("full", PixelFormat::RGBA8, Rect::new(-6, -4, 166, 124), 3, 0.6);
l.surface_mut().unwrap().fill_rect(Rect::new(40, 30, 90, 70), &[0.0, 0.0, 0.0, 0.0]);
l.effects.items = vec![
Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 6.0, 5.0, 0.0)),
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
];
d.layers.push(l);
let first = fx_check(&mut g, &d, "initial");
assert!(first.fx_programs > 0);
for (dx, dy) in [(3, 2), (5, -4), (-9, 7)] {
let s = d.layers[1].surface().unwrap();
let moved = s.translated(dx, dy, s.content_bounds());
*d.layers[1].surface_mut().unwrap() = moved;
let s = fx_check(&mut g, &d, &format!("moved by ({dx}, {dy})"));
assert_eq!(s.fx_programs, 0, "moved by ({dx}, {dy}): {s:?}");
}
}
#[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 depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
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, depth);
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", d.pixel_format(), Rect::new(30, 0, 60, 70), 9, 0.5);
c.clipped = true;
d.layers.push(c);
fx_check(&mut g, &d, &format!("{depth:?} 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:?}"));
}
}
}
/// Colours for the non-separable modes: in gamut, greys and near-greys above 1 (lum ≈ max, where
/// ClipColor divided by rounding noise), saturated colours above 1, negatives and mixes. All are
/// exact in half floats (the GPU's layer textures of 16/32-bit documents), so both compositors
/// see the same inputs.
const NONSEP_COLORS: [[f32; 3]; 12] = [
[0.25, 0.5, 0.75],
[1.0, 1.0, 1.0],
[0.0, 0.0, 0.0],
[1.5, 1.5, 1.5],
[2.0, 2.0, 2.0],
[4.0, 4.0, 4.0],
[2.0, 2.0, 1.996_093_8],
[4.0, 3.992_187_5, 4.0],
[3.0, 0.25, 0.125],
[0.125, 2.5, 0.375],
[1.25, 0.875, 3.75],
[-0.25, 0.5, 1.375],
];
/// Every pair of `colors` (backdrop column, source row) as one opaque pixel, in `mode`.
fn nonsep_doc(mode: BlendMode, depth: SampleType, colors: &[[f32; 3]]) -> Document {
let n = colors.len() as u32;
let mut d = Document::new("x", Size::new(n, n), 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 colors.iter().enumerate() {
for (j, s) in colors.iter().enumerate() {
let r = Rect::from_xywh(i as i32, j as i32, 1, 1);
bg.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&fmt, [b[0], b[1], b[2], 1.0]));
top.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&fmt, [s[0], s[1], s[2], 1.0]));
}
}
top.blend = mode;
d.layers.push(bg);
d.layers.push(top);
d
}
const NONSEP_MODES: [BlendMode; 6] =
[BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity, BlendMode::DarkerColor, BlendMode::LighterColor];
#[test]
fn non_separable_modes_on_hdr_values() {
// 32-bit documents hold values above 1 (and below 0): Hue / Saturation / Color / Luminosity
// used to divide by the rounding noise of `max − lum` in ClipColor there, which the CPU and
// GPU round differently (Hue over a grey of 2: CPU 1.0, GPU 2.82).
let Some(mut g) = gpu() else { return };
let mut failures = Vec::new();
for mode in NONSEP_MODES {
if let Err(e) = diff_rect(&mut g, &nonsep_doc(mode, SampleType::F32, &NONSEP_COLORS), Rect::new(0, 0, 12, 12), &format!("hdr {mode:?}")) {
failures.push(e);
}
}
// Integer documents store 0..1 only.
let in_gamut: Vec<[f32; 3]> = NONSEP_COLORS.iter().map(|c| c.map(|v| v.clamp(0.0, 1.0))).collect();
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for mode in NONSEP_MODES {
if let Err(e) = diff_rect(&mut g, &nonsep_doc(mode, depth, &in_gamut), Rect::new(0, 0, 12, 12), &format!("in gamut {mode:?} {depth:?}")) {
failures.push(e);
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn float_documents_blend_past_white() {
// 32-bit documents hold values above 1; Linear Dodge (Add) and Divide don't clip them there
// (integer depths do, see `blend_mode_extremes`). Values and alphas are exact in half floats.
// Separable modes only: the non-separable ones' ClipColor is ill-conditioned past white (a
// grey above 1 has max − lum ≈ 0), so CPU and GPU rounding diverge there.
let Some(mut g) = gpu() else { return };
let vals = [0.0f32, 0.25, 0.5, 1.0, 2.0, 4.0];
let alphas = [1.0f32, 0.5, 0.25];
for mode in BlendMode::LAYER_MODES.into_iter().filter(|m| m.is_separable()) {
let n = vals.len() as u32;
let mut d = Document::new("hdr", Size::new(n * 3, n), ColorMode::Rgb, SampleType::F32);
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, &a) in alphas.iter().enumerate() {
let r = Rect::from_xywh((i * 3 + k) as i32, j as i32, 1, 1);
bg.surface_mut().unwrap().fill_rect(r, &[b, b * 0.5, b, 1.0]);
top.surface_mut().unwrap().fill_rect(r, &[s, s, s * 0.5, a]);
}
}
}
top.blend = mode;
d.layers.push(bg);
d.layers.push(top);
check(&mut g, &d, &format!("hdr {mode:?}"));
if mode == BlendMode::LinearDodge {
// 4 + 0.25 at full opacity (column 5 × 3, row 1): unclipped on the GPU too.
let px = render_to_vec(&mut g.comp, &g.device, &g.queue, &d, Rect::from_xywh(15, 1, 1, 1)).unwrap();
assert!((px[0][0] - 4.25).abs() < 1e-3, "{:?}", px[0]);
}
}
}
#[test]
fn stroked_shapes_with_clipped_layers() {
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath};
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for (blend, masked) in [(BlendMode::Normal, false), (BlendMode::Multiply, true)] {
let mut d = Document::new("t", Size::new(80, 64), ColorMode::Rgb, depth);
d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3));
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", d.pixel_format(), Rect::new(0, 0, 80, 64), 42, 0.5);
c.clipped = true;
c.blend = BlendMode::Screen;
d.layers.extend([l, c]);
// Below 100 % fill the clipped layer is kept inside the shape's alpha, unfaded.
for fill in [1.0, 0.4, 0.0] {
d.layers[1].fill_opacity = fill;
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!("{depth:?} stroked shape + clipped {blend:?} masked {masked} fill {fill}"));
}
}
}
}
#[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}"));
}
}
/// An RGB pattern paints in the document's mode: grey in a Grayscale document, CMYK colours in
/// a CMYK one (fills and Pattern Overlay), on the GPU as on the CPU.
#[test]
fn rgb_patterns_paint_in_the_document_mode() {
let Some(mut g) = gpu() else { return };
let pat = checker_pattern();
for (mode, fmt) in [(ColorMode::Grayscale, PixelFormat::GRAYA8), (ColorMode::Cmyk, PixelFormat::CMYKA8)] {
let mut d = Document::new("m", Size::new(60, 40), mode, SampleType::U8);
d.layers.push(noise_layer("bg", fmt, Rect::new(0, 0, 60, 40), 63, 0.0));
d.patterns.push(pat.clone());
let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) };
let mut l = Layer::new("pat", LayerContent::Fill(fill));
l.mask = Some(mask(Rect::new(0, 0, 30, 40), 64, 0.0));
d.layers.push(l);
let mut o = blob("overlay", d.pixel_format(), 45.0, 20.0, 12.0, [0.9, 0.4, 0.2]);
o.effects.items = vec![Effect::PatternOverlay {
common: FxCommon::new(BlendMode::Normal, 1.0),
name: pat.name.clone(),
id: pat.id.clone(),
scale: 1.0,
angle: 0.0,
link: true,
phase: (0.0, 0.0),
}];
d.layers.push(o);
if mode == ColorMode::Grayscale {
let cpu = photocraft_compose::flatten(&d);
assert!(cpu.px.iter().all(|p| (p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6), "a Grayscale composite is neutral");
}
fx_check(&mut g, &d, &format!("RGB pattern in {mode:?}"));
}
}
#[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 (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut comp, &device, &queue, &d, d.bounds()).expect("16f cached render");
assert!(stats.prefix_hits > 0, "half-float checkpoints are reused: {stats:?}");
assert_eq!(cached, out, "restoring a half-float checkpoint preserves the uncached pixels");
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();
// Exercise input-page residency itself: prefix hits can legitimately skip those pages.
comp.set_composite_cache_budget(0);
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);
}
}