mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
# Conflicts: # crates/automation/src/rpc.rs # crates/automation/src/server.rs # crates/gpu/src/health.rs # crates/text/src/tests.rs # crates/ui-egui/src/i18n/zh-hans.tsv # crates/ui-egui/src/panels.rs # docs/control-protocol.md # docs/ui-parity.md
3094 lines
157 KiB
Rust
3094 lines
157 KiB
Rust
//! GPU vs CPU parity: every blend mode, adjustment, group/clip/mask/fill combination must match
|
||
//! the reference compositor within 2/255 (premultiplied). Skips when no GPU adapter exists.
|
||
|
||
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel};
|
||
use photocraft_doc::{Adjustment, Document, Fill, GradientStyle, Layer, LayerContent, LayerMask};
|
||
use photocraft_geom::{Rect, Size, TILE_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;
|
||
|
||
use crate::GPU_LOCK;
|
||
|
||
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,
|
||
},
|
||
// #7157: an edited curve continues past 0..1 along its end segments (Photoshop's `[[0,0],[255,128]]`).
|
||
Adjustment::Curves {
|
||
master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }],
|
||
per_channel: std::array::from_fn(|_| identity.clone()),
|
||
space: Default::default(),
|
||
black: Vec::new(),
|
||
pencil: false,
|
||
},
|
||
// 32-bit Photoshop rules: unclipped, legacy pivot 127, the mixer's 1/1024 steps and halved constant.
|
||
Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true },
|
||
Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true },
|
||
Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false },
|
||
Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0; 4], [0.0; 4]], monochrome: true },
|
||
Adjustment::PhotoFilter { color: [0.97, 0.76, 0.0], density: 0.4, preserve_luminosity: true },
|
||
Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false },
|
||
Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) },
|
||
Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None },
|
||
] {
|
||
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:?}"));
|
||
}
|
||
}
|
||
|
||
/// The default adapter, or the one wgpu's `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choose: e.g.
|
||
/// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc` tests D3D12 with FXC, the app's default on Windows,
|
||
/// where the default adapter may be Vulkan. A backend asked for that can't run the tests fails
|
||
/// them rather than skipping.
|
||
fn gpu() -> Option<Gpu> {
|
||
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
let required = std::env::var_os("WGPU_BACKEND").is_some();
|
||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
|
||
Ok(a) => a,
|
||
Err(e) => {
|
||
assert!(!required, "WGPU_BACKEND is set but has no adapter: {e}");
|
||
eprintln!("skipping GPU parity tests: no adapter ({e})");
|
||
return None;
|
||
}
|
||
};
|
||
let info = adapter.get_info();
|
||
let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
assert!(!required, "WGPU_BACKEND is set but {} has no device: {e}", info.name);
|
||
eprintln!("skipping GPU parity tests: no device ({e})");
|
||
return None;
|
||
}
|
||
};
|
||
eprintln!("GPU parity backend: {:?} ({}, {:?})", info.backend, info.name, info.device_type);
|
||
// 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 {
|
||
assert!(!required, "WGPU_BACKEND is set but {} can't render Rgba32Float", info.name);
|
||
eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float");
|
||
return None;
|
||
}
|
||
let compositor = || match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
assert!(!required, "WGPU_BACKEND is set but {} can't build the compositor: {e}", info.name);
|
||
eprintln!("skipping GPU parity tests: {e}");
|
||
None
|
||
}
|
||
};
|
||
let comp = compositor()?;
|
||
let mut paged = compositor()?;
|
||
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
|
||
}
|
||
|
||
/// `l` with a mask that reveals everything, so the executor draws it in a pass of its own rather
|
||
/// than batched with the layer below: prefix tests resume at any layer (only where a batch run
|
||
/// starts, `batch_runs`).
|
||
fn alone(mut l: Layer) -> Layer {
|
||
l.mask = Some(LayerMask::reveal_all());
|
||
l
|
||
}
|
||
|
||
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}"));
|
||
}
|
||
|
||
/// Growing a layer's tile footprint keeps the old texels in the overlap (`keep_overlap`)
|
||
/// while the same update rewrites tiles inside that overlap (#6243): whatever submission
|
||
/// shape the copy takes, the kept overlap must never overwrite tiles the same update
|
||
/// rewrote — the fresh texels win. Every step grows by one tile and rewrites everything
|
||
/// with per-step noise, on the whole-texture and the paged compositor alike, and each
|
||
/// render matches the CPU oracle.
|
||
#[test]
|
||
fn tile_growth_rewrite_keeps_fresh_texels_in_the_overlap() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let tile = TILE_SIZE as u32;
|
||
let steps = 4;
|
||
let mut doc = Document::new("tile growth", Size::new(steps * tile, tile), ColorMode::Rgb, SampleType::U8);
|
||
doc.layers.push(Layer::raster("grow", PixelFormat::RGBA8));
|
||
for step in 1..=steps {
|
||
// Wider by one tile than at the last render, so the resident's region grows and every
|
||
// tile inside the kept overlap changes: stale texels cannot masquerade as correct ones.
|
||
let w = step * tile;
|
||
let data: Vec<f32> = (0..w * tile * 4).map(|i| rnd(1000 + step, i)).collect();
|
||
doc.layers[0].surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, w, tile), &data);
|
||
check(&mut g, &doc, &format!("tile growth step {step}/{steps}"));
|
||
}
|
||
}
|
||
|
||
/// Closing a document frees what the compositor holds for it, however its canvas entry went
|
||
/// (#4529): `retain_docs` drops the resident pages, effect maps and lower-stack composite of
|
||
/// documents that aren't open, and keeps the open ones.
|
||
#[test]
|
||
fn retain_docs_frees_closed_documents() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let a = base_doc(64, 48);
|
||
let mut b = base_doc(32, 32);
|
||
b.id = photocraft_doc::DocId(a.id.0 + 1);
|
||
for d in [&a, &b] {
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, d, d.bounds()).expect("render");
|
||
}
|
||
assert_eq!(g.comp.held_docs(), vec![a.id, b.id]);
|
||
g.comp.retain_docs(|d| d == b.id);
|
||
assert_eq!(g.comp.held_docs(), vec![b.id], "only the open document is left");
|
||
g.comp.retain_docs(|_| false);
|
||
assert!(g.comp.held_docs().is_empty());
|
||
assert_eq!(g.comp.composite_cache_bytes(), 0);
|
||
check(&mut g, &a, "renders again after being forgotten");
|
||
}
|
||
|
||
#[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(alone(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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes
|
||
/// from the lower cached prefix and moves the cache up to just below the copy, so the undo and
|
||
/// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every
|
||
/// depth, also when plain layers are drawn several to a pass and the cache can only resume where
|
||
/// a batch starts.
|
||
#[test]
|
||
fn prefix_follows_an_edit_up_the_stack_and_stays_exact() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut uncached = Compositor::new(&g.device);
|
||
uncached.set_composite_cache_budget(0);
|
||
for (depth, batched) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|d| [(d, false), (d, true)]) {
|
||
let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth);
|
||
for seed in 1..=6 {
|
||
let l = noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2);
|
||
doc.layers.push(if batched { l } else { alone(l) });
|
||
}
|
||
let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats {
|
||
let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
|
||
let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap();
|
||
let (cold, cold_stats) = photocraft_gpu::render_to_vec_stats(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap();
|
||
assert_eq!(cached, cold, "{depth:?} batched {batched} {what}: reuse preserves the GPU accumulator exactly");
|
||
// Batched and single-layer draws needn't give the same bits on every backend (FXC
|
||
// on D3D12): a render that captures the prefix groups the layers as an uncached one
|
||
// does, wherever its checkpoint is.
|
||
if stats.prefix_hits == 0 {
|
||
assert_eq!(stats.layers_batched, cold_stats.layers_batched, "{depth:?} {what}: capturing changed the batches");
|
||
}
|
||
assert_eq!(cold_stats.layers_batched > 0, batched, "{depth:?} {what}");
|
||
stats
|
||
};
|
||
exact(&mut g, &doc, "populate");
|
||
// An edit low in the stack puts the cache there.
|
||
doc.layers[1].opacity = 0.6;
|
||
assert_eq!(exact(&mut g, &doc, "low edit").prefix_hits, 0);
|
||
let low = doc.clone();
|
||
let copy = doc.layers[4].duplicate();
|
||
doc.layers.insert(5, copy);
|
||
let first = exact(&mut g, &doc, "duplicate");
|
||
let undo = exact(&mut g, &low, "undo");
|
||
assert!(undo.prefix_hits > 0, "{depth:?} batched {batched}");
|
||
if !batched {
|
||
assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix");
|
||
assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}");
|
||
}
|
||
let redo = exact(&mut g, &doc, "redo");
|
||
assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?} batched {batched}");
|
||
// An edit below the moved cache still invalidates it.
|
||
doc.layers[2].opacity = 0.4;
|
||
assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?} batched {batched}");
|
||
}
|
||
}
|
||
|
||
/// A small refresh above the cache moves only the chunk it renders, which then holds just that
|
||
/// rectangle; the other chunks keep serving their lower checkpoint, and a full render afterwards
|
||
/// resumes each chunk from its own (#5286). Every render matches an uncached compositor exactly.
|
||
#[test]
|
||
fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
|
||
uncached.set_texture_limit(PAGED_LIMIT);
|
||
uncached.set_composite_cache_budget(0);
|
||
let mut doc = base_doc(600, 300);
|
||
for seed in 2..=5 {
|
||
doc.layers.push(alone(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)));
|
||
}
|
||
let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats {
|
||
let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap();
|
||
let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, rect).unwrap();
|
||
assert_eq!(cached, cold, "{what}: reuse preserves the GPU accumulator exactly");
|
||
let cpu = photocraft_compose::render(doc, rect);
|
||
assert!(worst_diff(&cpu.px, &cached).0 <= TOL, "{what}: GPU and CPU differ");
|
||
stats
|
||
};
|
||
render(&mut g, &doc, doc.bounds(), "populate");
|
||
doc.layers[1].opacity = 0.5;
|
||
let low = render(&mut g, &doc, doc.bounds(), "low edit");
|
||
assert_eq!(low.prefix_hits, 0);
|
||
// A copy near the top, refreshed in one chunk only.
|
||
let copy = doc.layers[3].duplicate();
|
||
doc.layers.insert(4, copy);
|
||
let small = render(&mut g, &doc, Rect::new(10, 10, 60, 60), "partial duplicate refresh");
|
||
assert_eq!(small.prefix_hits, 1, "{small:?}");
|
||
let full = render(&mut g, &doc, doc.bounds(), "full refresh after the partial one");
|
||
assert_eq!(full.prefix_hits, full.chunks - 1, "only the partly moved chunk misses: {full:?}");
|
||
let again = render(&mut g, &doc, doc.bounds(), "full refresh, all moved");
|
||
assert_eq!(again.prefix_hits, again.chunks);
|
||
assert!(again.prefix_passes_skipped > full.prefix_passes_skipped, "{full:?} {again:?}");
|
||
// Mixed checkpoints: a bottom-half refresh moves the chunks it covers whole further up
|
||
// (an edit at the top), the top half keeps the previous checkpoint and still hits.
|
||
doc.layers.last_mut().unwrap().opacity = 0.7;
|
||
let bottom = render(&mut g, &doc, Rect::new(0, 256, 600, 300), "bottom strip");
|
||
assert_eq!(bottom.prefix_hits, bottom.chunks);
|
||
let moved = render(&mut g, &doc, Rect::new(0, 0, 512, 256), "whole top chunks");
|
||
assert_eq!(moved.prefix_hits, moved.chunks);
|
||
let mixed = render(&mut g, &doc, doc.bounds(), "full refresh, mixed checkpoints");
|
||
assert_eq!(mixed.prefix_hits, mixed.chunks, "{mixed:?}");
|
||
}
|
||
|
||
#[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(alone(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2)));
|
||
doc.layers.push(alone(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].advanced.knockout = photocraft_doc::Knockout::Deep;
|
||
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 }
|
||
}
|
||
|
||
/// 32-bit Hue/Saturation on colours at and above HSL lightness 1 and with a negative channel,
|
||
/// where the HSL saturation divides by zero: the GPU scales the chroma as the CPU does
|
||
/// (compose::adjust::hue_saturation_in), finite and unclipped.
|
||
#[test]
|
||
fn hdr_hue_saturation_bright_colours() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = Document::new("t", Size::new(4, 1), ColorMode::Rgb, SampleType::F32);
|
||
let mut bg = Layer::raster("bg", d.pixel_format());
|
||
let px = [2.0, 0.0, 0.0, 1.0, 0.25, -0.25, 0.0, 1.0, 1.1, 0.9, 1.0, 1.0, 0.55, 0.45, 0.5, 1.0];
|
||
bg.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 4, 1), &px);
|
||
d.layers.push(bg);
|
||
for (hue, saturation) in [(40.0, 0.0), (0.0, 25.0), (0.0, -25.0), (40.0, 75.0)] {
|
||
let hs = Adjustment::HueSaturation { hue, saturation, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };
|
||
d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs)));
|
||
check(&mut g, &d, &format!("hue {hue} saturation {saturation} on bright colours"));
|
||
d.layers.pop();
|
||
}
|
||
// A PSD can store a Colorize saturation beyond 100: clamped before the 32-bit scaling on both.
|
||
let hs = Adjustment::HueSaturation { hue: 200.0, saturation: 150.0, lightness: 50.0, colorize: true, ranges: HueRange::defaults() };
|
||
d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs)));
|
||
check(&mut g, &d, "colorize saturation 150");
|
||
}
|
||
|
||
#[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:?}"));
|
||
// Colorize: the saturation scaled by 1 - |lightness| at 32 bits (compose::adjust::colorize_saturation).
|
||
let hs = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() };
|
||
d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs)));
|
||
check(&mut g, &d, &format!("colorize {depth:?}"));
|
||
// Hue/Saturation: at 32 bits the raised saturation isn't clipped.
|
||
let hs = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() };
|
||
d.layers.push(Layer::new("hs2", LayerContent::Adjustment(hs)));
|
||
check(&mut g, &d, &format!("hue/saturation {depth:?}"));
|
||
// Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity.
|
||
d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 })));
|
||
check(&mut g, &d, &format!("threshold {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}"));
|
||
}
|
||
// #3052/#4921: a clipped Dissolve layer retains opacity/fill binary coverage
|
||
// across the partially covered clipping base; the GPU must match the CPU.
|
||
for (opacity, fill) in [(0.4, 1.0), (0.8, 0.5)] {
|
||
let mut d = base_doc(56, 44);
|
||
let base = noise_layer("base", PixelFormat::RGBA8, Rect::new(5, 6, 48, 40), 45, 0.0);
|
||
let mut clip = noise_layer("dissolve clip", PixelFormat::RGBA8, d.bounds(), 46, 0.0);
|
||
clip.clipped = true;
|
||
clip.blend = BlendMode::Dissolve;
|
||
clip.opacity = opacity;
|
||
clip.fill_opacity = fill;
|
||
d.layers.extend([base, clip]);
|
||
check(&mut g, &d, "clipped Dissolve opacity/fill CPU/GPU parity");
|
||
}
|
||
// 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, interpolation: None },
|
||
Effect::Stroke(StrokeFx {
|
||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||
size: 1.0,
|
||
position: StrokePosition::Inside,
|
||
paint: paint.clone(),
|
||
overprint: false,
|
||
}),
|
||
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 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"). 1200 layers need well over 1000 passes in the document's only chunk: each is
|
||
// masked, so it is drawn in a pass of its own rather than batched with its neighbours.
|
||
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(1200).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;
|
||
l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0));
|
||
d.layers.push(l);
|
||
}
|
||
let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}"));
|
||
assert_eq!(stats.layers_batched, 0, "{stats:?}");
|
||
assert_eq!(stats.chunks, 1);
|
||
assert!(stats.flushes > 0, "{stats:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn runs_of_plain_layers_blend_in_one_pass() {
|
||
// Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at
|
||
// varied opacity and fill (the fill-special modes included), solid fills, layers that end
|
||
// inside the canvas, and the layers that end a run: masked, Dissolve, small (clipped to their
|
||
// bounds) and clipping groups. Then an edit in the middle, so the prefix cache resumes
|
||
// part-way up the stack and runs start at its checkpoint.
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = base_doc(96, 64);
|
||
let modes: Vec<BlendMode> = BlendMode::layer_modes().collect();
|
||
for (i, mode) in modes.iter().copied().cycle().take(90).enumerate() {
|
||
let i32_ = i as i32;
|
||
let rect = if i % 5 == 2 { Rect::from_xywh(-8, 10, 70, 40) } else { Rect::from_xywh(0, 0, 96, 64) };
|
||
let mut l = noise_layer("l", PixelFormat::RGBA8, rect, 900 + i as u32, 0.0);
|
||
l.blend = mode;
|
||
l.opacity = 0.35 + 0.05 * (i % 13) as f32;
|
||
l.fill_opacity = if i % 4 == 1 { 0.6 } else { 1.0 };
|
||
if i % 11 == 7 {
|
||
l.mask = Some(mask(Rect::from_xywh(i32_ % 30, 0, 50, 64), 70 + i as u32, 0.0));
|
||
}
|
||
if i % 23 == 9 {
|
||
l.clipped = true;
|
||
}
|
||
d.layers.push(l);
|
||
if i % 9 == 4 {
|
||
let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.3, 0.6, 0.2, 0.4))));
|
||
f.blend = modes[i % modes.len()];
|
||
d.layers.push(f);
|
||
}
|
||
if i % 17 == 8 {
|
||
d.layers.push(noise_layer("small", PixelFormat::RGBA8, Rect::from_xywh(20, 20, 6, 5), 40 + i as u32, 0.5));
|
||
}
|
||
}
|
||
let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs").unwrap_or_else(|e| panic!("{e}"));
|
||
assert!(stats.layers_batched > 0, "no layers were batched: {stats:?}");
|
||
let mid = d.layers.len() / 2;
|
||
d.layers[mid].opacity = 0.5;
|
||
let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs, edited in the middle").unwrap_or_else(|e| panic!("{e}"));
|
||
assert!(stats.layers_batched > 0, "{stats:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn runs_of_normal_layers_blend_with_normal_code_alone() {
|
||
// Runs whose layers all blend Normal draw with `fs_layerbatch_normal`: varied opacity and fill
|
||
// over a partly transparent backdrop, masked layers leading runs, solid fills and layers that
|
||
// end inside the canvas, at every depth. Lab documents mix Normal in CIELAB, so their runs
|
||
// keep the full shader.
|
||
let Some(mut g) = gpu() else { return };
|
||
for (mode, depth) in
|
||
[(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Lab, SampleType::U8)]
|
||
{
|
||
let mut d = Document::new("normal", Size::new(96, 64), mode, depth);
|
||
let fmt = d.pixel_format();
|
||
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 96, 64), 5, 0.0));
|
||
for i in 0..40u32 {
|
||
let rect = if i % 5 == 3 { Rect::from_xywh(-6, 9, 70, 44) } else { Rect::from_xywh(0, 0, 96, 64) };
|
||
let mut l = noise_layer("n", fmt, rect, 600 + i, if i % 3 == 0 { 0.0 } else { 0.4 });
|
||
l.opacity = 0.2 + 0.07 * (i % 11) as f32;
|
||
l.fill_opacity = [1.0, 0.55, 0.0, 0.8][i as usize % 4];
|
||
if i % 7 == 2 {
|
||
l.mask = Some(mask(Rect::from_xywh(i as i32 % 25, 0, 60, 64), 50 + i, 0.0));
|
||
}
|
||
d.layers.push(l);
|
||
if i % 6 == 4 {
|
||
let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.7, 0.2, 0.5, 0.6))));
|
||
f.opacity = 0.45;
|
||
d.layers.push(f);
|
||
}
|
||
}
|
||
let what = format!("normal runs {mode:?} {depth:?}");
|
||
let stats = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}"));
|
||
if mode == ColorMode::Lab {
|
||
assert!(stats.layers_batched > 0 && stats.normal_batches == 0, "{what}: {stats:?}");
|
||
} else {
|
||
assert!(stats.normal_batches > 0, "{what}: {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,
|
||
gloss_anti_alias: false,
|
||
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, overprint: false }
|
||
}
|
||
|
||
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;
|
||
// #7360: a gloss contour that starts at 100 % (Rolling Slope - Descending), as on the type in
|
||
// the report; its flat face and slopes take the contour-mapped shading on both renderers.
|
||
let mut bevel_gloss_high = bevel(BevelStyle::InnerBevel, true, 8.0, 2.0);
|
||
bevel_gloss_high.gloss_contour = Contour::Custom {
|
||
name: "descending".into(),
|
||
points: vec![Cp { input: 0.0, output: 1.0 }, Cp { input: 0.4, output: 0.4 }, Cp { input: 1.0, output: 0.0 }],
|
||
corners: vec![],
|
||
};
|
||
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))]),
|
||
// Dissolve effects (#6995): the same speckle as the CPU, pixel for pixel.
|
||
(
|
||
"outer glow dissolve",
|
||
vec![Effect::OuterGlow(Glow {
|
||
common: FxCommon::new(BlendMode::Dissolve, 0.8),
|
||
..glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge)
|
||
})],
|
||
),
|
||
(
|
||
"inner glow dissolve gradient",
|
||
vec![Effect::InnerGlow(Glow {
|
||
common: FxCommon::new(BlendMode::Dissolve, 1.0),
|
||
..glow(FxPaint::Gradient(gradient()), GlowTechnique::Softer, 7.0, 0.1, GlowSource::Edge)
|
||
})],
|
||
),
|
||
("drop shadow dissolve", vec![Effect::DropShadow(shadow(BlendMode::Dissolve, 0.75, 120.0, 6.0, 8.0, 0.0))]),
|
||
(
|
||
"stroke outside dissolve",
|
||
vec![Effect::Stroke(StrokeFx {
|
||
common: FxCommon::new(BlendMode::Dissolve, 0.6),
|
||
..stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9)))
|
||
})],
|
||
),
|
||
("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)]),
|
||
("bevel gloss starting high", vec![Effect::BevelEmboss(bevel_gloss_high)]),
|
||
("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,
|
||
interpolation: None,
|
||
}],
|
||
),
|
||
(
|
||
"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, interpolation: None },
|
||
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 exterior_effects_merge_from_the_shape_at_any_fill() {
|
||
// Drop shadows and outer glows alone: the merge builds the layer from its shape (fill inside
|
||
// it, times its alpha) instead of passes of its own. Fill from 0 to 1, opacity, modes
|
||
// (including one that blends with the knocked-out shadow), both depths, a layer covering the
|
||
// canvas (no copy back), and the layer clipped onto a base (merged atop it).
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut ds = shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0);
|
||
let mut ds_through = shadow(BlendMode::Normal, 0.9, 45.0, 4.0, 5.0, 0.2);
|
||
ds_through.knocks_out = false;
|
||
let og = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge);
|
||
ds.use_global_light = true;
|
||
let stacks = [
|
||
("drop shadow", vec![Effect::DropShadow(ds.clone())]),
|
||
("drop shadow, not knocked out", vec![Effect::DropShadow(ds_through)]),
|
||
("outer glow", vec![Effect::OuterGlow(og.clone())]),
|
||
("shadow and glow", vec![Effect::OuterGlow(og), Effect::DropShadow(ds)]),
|
||
];
|
||
for depth in [SampleType::U8, SampleType::U16] {
|
||
for (name, fx) in &stacks {
|
||
for (opacity, fill, blend) in [
|
||
(1.0, 1.0, BlendMode::Normal),
|
||
(0.8, 0.6, BlendMode::Normal),
|
||
(1.0, 0.0, BlendMode::Normal),
|
||
(0.7, 0.35, BlendMode::Multiply),
|
||
(1.0, 0.5, BlendMode::Screen),
|
||
] {
|
||
let what = format!("{name} {depth:?} opacity {opacity} fill {fill} {blend:?}");
|
||
let mut d = fx_doc(96, 80, depth);
|
||
let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]);
|
||
l.effects.items = fx.clone();
|
||
(l.opacity, l.fill_opacity, l.blend) = (opacity, fill, blend);
|
||
d.layers.push(l.clone());
|
||
fx_check(&mut g, &d, &what);
|
||
// The same layer clipped onto a base.
|
||
let mut base = blob("base", d.pixel_format(), 40.0, 44.0, 30.0, [0.9, 0.8, 0.3]);
|
||
base.effects.items.clear();
|
||
d.layers.pop();
|
||
d.layers.push(base);
|
||
l.clipped = true;
|
||
d.layers.push(l.clone());
|
||
fx_check(&mut g, &d, &format!("{what}, clipped"));
|
||
// A partly transparent layer over the whole canvas.
|
||
let mut d = fx_doc(64, 48, depth);
|
||
let mut full = noise_layer("full", d.pixel_format(), Rect::from_xywh(0, 0, 64, 48), 7, 0.2);
|
||
full.effects.items = fx.clone();
|
||
(full.opacity, full.fill_opacity, full.blend) = (opacity, fill, blend);
|
||
d.layers.push(full);
|
||
fx_check(&mut g, &d, &format!("{what}, full canvas"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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),
|
||
(1.0, 1.0, BlendMode::Color),
|
||
] {
|
||
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}"));
|
||
}
|
||
// An Outside stroke has no share beneath fully opaque pixels (#5861): non-Normal layers with
|
||
// an Outside stroke on a soft blob and a hard square. (Blend Interior Effects as Group, where
|
||
// that share shows through the layer's mode, is composited on the CPU.)
|
||
for blend in [BlendMode::Multiply, BlendMode::Screen] {
|
||
let mut d = fx_doc(80, 80, SampleType::U8);
|
||
let mut l = blob("ring", d.pixel_format(), 40.0, 40.0, 20.0, [0.9, 0.1, 0.1]);
|
||
l.effects.items = vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9))))];
|
||
l.blend = blend;
|
||
d.layers.push(l);
|
||
fx_check(&mut g, &d, &format!("outside stroke under a {blend:?} blob"));
|
||
let mut d = fx_doc(40, 40, SampleType::U8);
|
||
let mut l = Layer::raster("square", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
|
||
l.effects.items = vec![Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))))];
|
||
l.blend = blend;
|
||
d.layers.push(l);
|
||
fx_check(&mut g, &d, &format!("outside stroke under a {blend:?} square"));
|
||
}
|
||
// 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, ..ShapeLayer::default() };
|
||
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, ..ShapeLayer::default() };
|
||
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}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Blend If on the GPU matches `compose::apply_blend_if` (#4395): this-layer and underlying
|
||
/// ranges, hard and split points, per channel, on raster, masked, adjustment and clipped layers,
|
||
/// with channel restrictions, and in grayscale. Hard points are compared on 8-bit documents only:
|
||
/// 16/32-bit layers reach the GPU as RGBA16F, so a value within about a tenth of a level of a
|
||
/// hard point can land on its other side (as with the GPU Threshold adjustment, also compared at
|
||
/// 8 bits); split points are compared at every depth.
|
||
#[test]
|
||
fn blend_if() {
|
||
use photocraft_doc::BlendRange;
|
||
let Some(mut g) = gpu() else { return };
|
||
let r = |b0: u8, b1: u8, w0: u8, w1: u8| BlendRange { black: [b0, b1], white: [w0, w1] };
|
||
// (name, entry, [this layer, underlying], has a hard point)
|
||
let cases: [(&str, usize, [BlendRange; 2], bool); 7] = [
|
||
("gray this hard", 0, [r(90, 90, 255, 255), BlendRange::FULL], true),
|
||
("gray under split", 0, [BlendRange::FULL, r(30, 140, 150, 230)], false),
|
||
("red both", 1, [r(0, 60, 200, 200), r(20, 20, 180, 250)], true),
|
||
("red both split", 1, [r(0, 60, 190, 230), r(10, 50, 180, 250)], false),
|
||
("green white split", 2, [r(0, 0, 100, 220), BlendRange::FULL], false),
|
||
("blue under hard", 3, [BlendRange::FULL, r(128, 128, 255, 255)], true),
|
||
("everything hidden", 0, [r(255, 255, 0, 0), BlendRange::FULL], true),
|
||
];
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let fmt = PixelFormat { sample: depth, ..PixelFormat::RGBA8 };
|
||
for (name, e, pair, hard) in cases {
|
||
if hard && depth != SampleType::U8 {
|
||
continue;
|
||
}
|
||
let mut d = Document::new("bi", Size::new(64, 48), ColorMode::Rgb, depth);
|
||
d.layers.push(noise_layer("bg", fmt, Rect::new(0, 0, 64, 48), 1, 0.3));
|
||
let mut l = noise_layer("top", fmt, Rect::new(4, 3, 60, 45), 2, 0.0);
|
||
l.blend = BlendMode::Multiply;
|
||
l.opacity = 0.8;
|
||
l.blend_if.set(e, pair);
|
||
d.layers.push(l.clone());
|
||
check(&mut g, &d, &format!("{depth:?} {name}"));
|
||
// Edits above it reuse the lower stack, Blend If included (prefix checkpoints).
|
||
let mut above = d.clone();
|
||
above.layers.push(noise_layer("above", fmt, Rect::new(20, 0, 40, 48), 4, 0.5));
|
||
check(&mut g, &above, &format!("{depth:?} {name} under another layer"));
|
||
above.layers[2].opacity = 0.4;
|
||
let hits = diff_rect(&mut g, &above, above.bounds(), &format!("{depth:?} {name} edit above")).unwrap_or_else(|e| panic!("{e}"));
|
||
assert!(hits.prefix_hits > 0, "{depth:?} {name}: the edit above reuses the lower stack");
|
||
|
||
// Masked, with channel restrictions.
|
||
let mut m = l.clone();
|
||
m.mask = Some(mask(Rect::new(0, 0, 64, 48), 5, 1.0));
|
||
m.excluded_channels = 0b010;
|
||
d.layers[1] = m;
|
||
check(&mut g, &d, &format!("{depth:?} {name} masked, channels"));
|
||
|
||
// An adjustment judged by its result, and a clipped layer.
|
||
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||
adj.blend_if.set(e, pair);
|
||
d.layers[1] = l.clone();
|
||
d.layers[1].blend_if = Default::default();
|
||
d.layers.push(adj);
|
||
let mut c = noise_layer("clip", fmt, Rect::new(10, 10, 50, 40), 3, 0.2);
|
||
c.clipped = true;
|
||
c.blend_if.set(e, pair);
|
||
d.layers.push(c);
|
||
check(&mut g, &d, &format!("{depth:?} {name} adjustment, clipped"));
|
||
}
|
||
}
|
||
// Grayscale: the one channel's entries.
|
||
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.blend_if.set(1, [r(60, 120, 255, 255), r(0, 0, 140, 200)]);
|
||
d.layers.push(l);
|
||
check(&mut g, &d, "gray blend if");
|
||
}
|
||
|
||
#[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,
|
||
..ShapeLayer::default()
|
||
};
|
||
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]);
|
||
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}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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:?}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sparse patterns may have no stored tiles: their defaults still determine every pixel.
|
||
#[test]
|
||
fn pattern_cache_tracks_default_pixels() {
|
||
let Some(mut g) = gpu() else { return };
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let fmt = PixelFormat::RGBA8.with_sample(depth);
|
||
let mut d = Document::new("pattern defaults", Size::new(8, 6), ColorMode::Rgb, depth);
|
||
let mut pat = Pattern::new("cache identity", photocraft_raster::Surface::with_default(fmt, &[1.0, 0.0, 0.0, 1.0]), 2, 2);
|
||
assert_eq!(pat.surface.tiles().count(), 0);
|
||
d.layers.push(Layer::new(
|
||
"pattern",
|
||
LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }),
|
||
));
|
||
d.patterns.push(pat.clone());
|
||
check(&mut g, &d, &format!("red default {depth:?}"));
|
||
pat.surface = photocraft_raster::Surface::with_default(fmt, &[0.0, 0.0, 1.0, 1.0]);
|
||
assert_eq!(pat.surface.tiles().count(), 0);
|
||
d.patterns[0] = pat;
|
||
let cpu = photocraft_compose::flatten(&d);
|
||
let mut fresh = Compositor::new(&g.device);
|
||
let out = render_to_vec(&mut fresh, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||
assert!(worst_diff(&cpu.px, &out).0 <= TOL, "fresh compositor matches blue default");
|
||
check(&mut g, &d, &format!("blue default {depth:?}"));
|
||
}
|
||
}
|
||
|
||
/// A document that paints one `side` x `side` pattern named `name`.
|
||
fn pattern_fill_doc(name: &str, side: u32) -> Document {
|
||
let mut d = Document::new(name, Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
|
||
let pat = Pattern::new(name, photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]), side, side);
|
||
d.layers.push(Layer::new(
|
||
"pattern",
|
||
LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }),
|
||
));
|
||
d.patterns.push(pat);
|
||
d
|
||
}
|
||
|
||
/// #4971: a pattern texture belongs to the document that last painted with it, and closing that
|
||
/// document frees it. Before, pattern textures were kept until the device was lost.
|
||
#[test]
|
||
fn closed_documents_release_their_pattern_textures() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let a = pattern_fill_doc("first", 4);
|
||
let mut b = pattern_fill_doc("second", 4);
|
||
b.id = photocraft_doc::DocId(a.id.0 + 1);
|
||
let one = 4 * 4 * 16;
|
||
assert_eq!(g.comp.pattern_cache_bytes(), 0);
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a");
|
||
assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture");
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b");
|
||
assert_eq!(g.comp.pattern_cache_bytes(), 2 * one);
|
||
g.comp.forget_doc(a.id);
|
||
assert_eq!(g.comp.pattern_cache_bytes(), one, "closing a document frees its pattern");
|
||
assert_eq!(g.comp.held_docs(), vec![b.id]);
|
||
g.comp.retain_docs(|_| false);
|
||
assert_eq!(g.comp.pattern_cache_bytes(), 0);
|
||
check(&mut g, &a, "the pattern is uploaded again after being forgotten");
|
||
}
|
||
|
||
/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and
|
||
/// by age. What the current frame paints with is never dropped.
|
||
#[test]
|
||
fn pattern_textures_are_evicted_over_budget_and_when_idle() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let a = pattern_fill_doc("first", 4);
|
||
let mut b = pattern_fill_doc("second", 4);
|
||
b.id = photocraft_doc::DocId(a.id.0 + 1);
|
||
let mut plain = base_doc(8, 6);
|
||
plain.id = photocraft_doc::DocId(a.id.0 + 2);
|
||
let one = 4 * 4 * 16;
|
||
// Room for one pattern and a half: painting the second drops the first.
|
||
g.comp.set_pattern_budget(one + one / 2);
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a");
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b");
|
||
assert_eq!(g.comp.pattern_cache_bytes(), one, "the least recently painted pattern went");
|
||
// A budget below a single pattern still keeps the one being painted.
|
||
g.comp.set_pattern_budget(0);
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b again");
|
||
assert_eq!(g.comp.pattern_cache_bytes(), one);
|
||
// Idle: frames that paint no pattern age it out.
|
||
g.comp.set_pattern_budget(photocraft_gpu::PATTERN_BUDGET);
|
||
for _ in 0..photocraft_gpu::PATTERN_IDLE_FRAMES - 1 {
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain");
|
||
}
|
||
assert_eq!(g.comp.pattern_cache_bytes(), one, "still within the idle limit");
|
||
render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain");
|
||
assert_eq!(g.comp.pattern_cache_bytes(), 0, "dropped after {} idle frames", photocraft_gpu::PATTERN_IDLE_FRAMES);
|
||
check(&mut g, &b, "the pattern is uploaded again after being evicted");
|
||
}
|
||
|
||
#[test]
|
||
fn pattern_cache_tracks_source_format_and_cow_tiles() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let defaults = [0.5, 0.25, 0.75, 1.0];
|
||
let mut pat = Pattern::new("format identity", photocraft_raster::Surface::with_default(PixelFormat::RGBA32F, &defaults), 2, 2);
|
||
let mut d = Document::new("pattern formats", Size::new(8, 6), ColorMode::Rgb, SampleType::F32);
|
||
d.layers.push(Layer::new(
|
||
"pattern",
|
||
LayerContent::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 previous = None;
|
||
for fmt in [PixelFormat::RGBA32F, PixelFormat::new(ColorMode::Lab, SampleType::F32, true), PixelFormat::RGBA16, PixelFormat::RGBA8] {
|
||
pat.surface = photocraft_raster::Surface::with_default(fmt, &defaults);
|
||
if fmt.sample == SampleType::F32 {
|
||
assert_eq!(pat.surface.default_pixel(), defaults);
|
||
}
|
||
d.patterns = vec![pat.clone()];
|
||
let cpu = photocraft_compose::flatten(&d);
|
||
if fmt.mode == ColorMode::Lab {
|
||
let old: Vec<[f32; 4]> = previous.take().unwrap();
|
||
assert!(worst_diff(&old, &cpu.px).0 > 0.1, "same default bits have different source-format colours");
|
||
}
|
||
previous = Some(cpu.px.clone());
|
||
let mut fresh = Compositor::new(&g.device);
|
||
let out = render_to_vec(&mut fresh, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||
assert!(worst_diff(&cpu.px, &out).0 <= TOL, "fresh compositor matches {fmt:?}");
|
||
check(&mut g, &d, &format!("source format {fmt:?}"));
|
||
check(&mut g, &d, &format!("unchanged source format {fmt:?}"));
|
||
}
|
||
pat.surface.fill_rect(pat.rect(), &[1.0, 0.0, 0.0, 1.0]);
|
||
d.patterns = vec![pat.clone()];
|
||
check(&mut g, &d, "stored red tile");
|
||
// Keep the old tile alive: this exercises COW invalidation, not allocator address reuse.
|
||
let old = pat.clone();
|
||
pat.surface.fill_rect(Rect::new(0, 0, 1, 1), &[0.0, 0.0, 1.0, 1.0]);
|
||
assert!(!std::sync::Arc::ptr_eq(old.surface.tiles().next().unwrap().1, pat.surface.tiles().next().unwrap().1));
|
||
d.patterns = vec![pat];
|
||
check(&mut g, &d, "COW blue tile edit");
|
||
let mut fresh = Compositor::new(&g.device);
|
||
let out = render_to_vec(&mut fresh, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||
assert!(worst_diff(&photocraft_compose::flatten(&d).px, &out).0 <= TOL, "fresh compositor matches COW edit");
|
||
assert_eq!(old.surface.tile_count(), 1);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// #6563: a page texture that doesn't fit the budget is admitted by first dropping the least
|
||
/// recently used pages of other cells (never the current cell's). With a zero budget every page
|
||
/// created in a cell evicts the previous cell's pages, and the refresh still stays on the GPU
|
||
/// (budget pressure alone never ends it on the CPU) and matches the CPU compositor.
|
||
#[test]
|
||
fn layer_pages_are_admitted_under_a_zero_budget_and_stay_on_the_gpu() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = base_doc(600, 500);
|
||
d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(-20, 10, 590, 480), 61, 0.0));
|
||
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(30, -15, 620, 510), 62, 0.2);
|
||
top.blend = BlendMode::Multiply;
|
||
d.layers.push(top);
|
||
// Exercise page residency itself: lower-stack checkpoints could skip binding the pages.
|
||
g.paged.set_composite_cache_budget(0);
|
||
check(&mut g, &d, "warm");
|
||
let all = g.paged.resident_bytes();
|
||
g.paged.set_resident_budget(0);
|
||
for round in 0..2 {
|
||
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_or_else(|e| panic!("round {round}: a zero budget must not end the refresh on the CPU: {e}"));
|
||
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.cells > 1 && s.evicted > 0, "round {round}: {s:?}");
|
||
// Evicted pages were created again (uploaded), and only the last cell's are left.
|
||
assert!(s.tiles_uploaded > 0, "round {round}: {s:?}");
|
||
assert!(g.paged.resident_bytes() < all, "round {round}: {} of {all}", g.paged.resident_bytes());
|
||
d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(40 + round * 250, 60, 90 + round * 250, 120), &[0.2, 0.8, 0.3, 1.0]);
|
||
}
|
||
check(&mut g, &d, "after zero-budget renders");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layer_pages_that_just_miss_borrow_the_effect_map_share() {
|
||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||
let Some(g) = gpu() else { return };
|
||
let d = big_doc();
|
||
let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
|
||
comp.set_texture_limit(PAGED_LIMIT);
|
||
let need = comp.working_set(&d, d.bounds());
|
||
// Pages get three quarters of the budget and effect maps an eighth: at 5/4 of the need the
|
||
// pages' own share misses by about 6 %, which the effect-map share covers (#5973, P60).
|
||
let budget = need / 4 * 5;
|
||
comp.set_memory_budget(budget);
|
||
let own = comp.resident_budget();
|
||
assert!(own < need && !comp.fits_budget(&d, d.bounds()));
|
||
assert_eq!(comp.fit_layers(need, true), need, "lends exactly the shortfall");
|
||
assert_eq!(comp.memory_budget(), budget, "the total stays");
|
||
comp.set_memory_budget(budget);
|
||
assert_eq!(comp.resident_budget(), need, "the loan holds across the budget set before every refresh");
|
||
assert_eq!(comp.fit_layers(need / 2, false), need, "a partial refresh doesn't shrink it");
|
||
photocraft_gpu::render_to_vec(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||
// Every page stays resident; effect maps may be evicted and rebuilt, that's the loan.
|
||
assert_eq!(s.tiles_uploaded, 0, "{s:?}");
|
||
assert!(worst_diff(&photocraft_compose::render(&d, d.bounds()).px, &out).0 <= TOL);
|
||
// A need the effect-map share can't cover lends nothing (it would only evict maps), and a
|
||
// smaller need gives the loan back.
|
||
assert_eq!(comp.fit_layers(budget, true), own);
|
||
comp.fit_layers(need, true);
|
||
assert_eq!(comp.fit_layers(own, true), own);
|
||
assert_eq!(comp.memory_budget(), budget);
|
||
// An exact resident budget returns the loan and lends nothing until the next memory budget.
|
||
comp.fit_layers(need, true);
|
||
comp.set_resident_budget(own);
|
||
assert_eq!(comp.memory_budget(), budget, "the loan went back to the effect maps");
|
||
assert_eq!(comp.fit_layers(need, true), own, "an exact budget stays exact");
|
||
comp.set_memory_budget(budget);
|
||
assert_eq!(comp.resident_budget(), need, "the next memory budget lends again");
|
||
}
|
||
|
||
// A pass-through group holding an adjustment renders isolated when layers clip to it, on the GPU
|
||
// as on the CPU (psd-tools passthrough_clipping_mask_adjustment).
|
||
#[test]
|
||
fn pass_through_clipping_base_with_an_adjustment() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = base_doc(32, 24);
|
||
d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(0, 0, 32, 24), 41, 1.0));
|
||
let mut grp = Layer::group(
|
||
"g",
|
||
vec![noise_layer("in", PixelFormat::RGBA8, Rect::new(4, 4, 20, 20), 42, 0.5), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))],
|
||
);
|
||
grp.blend = BlendMode::PassThrough;
|
||
d.layers.push(grp);
|
||
let mut clip = noise_layer("clip", PixelFormat::RGBA8, Rect::new(8, 0, 28, 16), 43, 0.3);
|
||
clip.clipped = true;
|
||
d.layers.push(clip);
|
||
check(&mut g, &d, "pass-through clipping base with an adjustment");
|
||
}
|
||
|
||
/// #5846: a shape's Density and Feather soften its fill (its own rendered pixels), with and
|
||
/// without a vector stroke over clipped layers and layer effects: the GPU matches the CPU.
|
||
#[test]
|
||
fn soft_shapes_match_the_cpu() {
|
||
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 (density, feather, stroked, fx) in [(1.0, 6.0, false, false), (0.4, 0.0, true, false), (0.6, 4.0, true, true), (1.0, 3.0, false, 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 stroke = stroked.then(|| ShapeStroke { width: 3.0, paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() });
|
||
let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.3, 0.6, 0.9))), stroke, density, feather, ..ShapeLayer::default() };
|
||
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||
let mut l = Layer::new("shape", LayerContent::Shape(sh));
|
||
if fx {
|
||
l.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0))];
|
||
}
|
||
d.layers.push(l);
|
||
if stroked {
|
||
let mut c = noise_layer("clipped", d.pixel_format(), Rect::from_xywh(0, 0, 90, 70), 11, 0.9);
|
||
c.clipped = true;
|
||
d.layers.push(c);
|
||
}
|
||
let what = format!("{depth:?} density {density} feather {feather} stroked {stroked} fx {fx}");
|
||
if fx {
|
||
fx_check(&mut g, &d, &what);
|
||
} else {
|
||
check(&mut g, &d, &what);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An adjustment has no standalone source alpha: its opacity and mask gate binary coverage.
|
||
#[test]
|
||
fn dissolve_adjustment_matches_cpu() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = base_doc(64, 48);
|
||
let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
|
||
adjustment.blend = BlendMode::Dissolve;
|
||
adjustment.opacity = 0.5;
|
||
adjustment.mask = Some(mask(Rect::new(0, 0, 32, 48), 31, 0.0));
|
||
d.layers.push(adjustment);
|
||
check(&mut g, &d, "dissolve adjustment + mask");
|
||
}
|
||
|
||
/// Clipped pixels still use document-positioned noise, without changing the base alpha.
|
||
#[test]
|
||
fn dissolve_clipped_layer_matches_cpu() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = base_doc(64, 48);
|
||
let mut clipped = Layer::new("clipped", LayerContent::Fill(Fill::Solid(Color::BLACK)));
|
||
clipped.clipped = true;
|
||
clipped.blend = BlendMode::Dissolve;
|
||
clipped.opacity = 0.5;
|
||
d.layers.push(clipped);
|
||
check(&mut g, &d, "dissolve clipped");
|
||
}
|
||
|
||
/// Layer opacity gates the completed effects stack, instead of fading its pixels.
|
||
#[test]
|
||
fn dissolve_effects_stack_matches_cpu() {
|
||
let Some(mut g) = gpu() else { return };
|
||
let mut d = base_doc(64, 48);
|
||
let mut styled = Layer::new("styled", LayerContent::Fill(Fill::Solid(Color::BLACK)));
|
||
styled.blend = BlendMode::Dissolve;
|
||
styled.opacity = 0.5;
|
||
styled.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK });
|
||
d.layers.push(styled);
|
||
check(&mut g, &d, "dissolve effects");
|
||
}
|
||
|
||
#[test]
|
||
fn dissolve_clipped_adjustment_matches_cpu() {
|
||
let Some(mut g) = gpu() else { return };
|
||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||
let mut d = Document::new("clipped adjustment", Size::new(272, 24), ColorMode::Rgb, fmt.sample);
|
||
let base = noise_layer("base", fmt, Rect::new(-3, -2, 268, 22), 47, 0.0);
|
||
let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
|
||
adjustment.clipped = true;
|
||
adjustment.blend = BlendMode::Dissolve;
|
||
adjustment.opacity = 0.6;
|
||
adjustment.fill_opacity = 0.5;
|
||
adjustment.mask = Some(mask(Rect::new(0, 0, 260, 24), 48, 0.0));
|
||
d.layers.extend([base, adjustment]);
|
||
check(&mut g, &d, &format!("clipped Dissolve adjustment {fmt:?}"));
|
||
diff_rect(&mut g, &d, Rect::new(250, 1, 270, 20), "clipped adjustment offset region").unwrap_or_else(|e| panic!("{e}"));
|
||
}
|
||
}
|