diff --git a/Cargo.lock b/Cargo.lock index 43346228..6b7e8eb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3418,6 +3418,7 @@ dependencies = [ "photocraft-doc", "photocraft-geom", "photocraft-raster", + "photocraft-vector", "pollster 1.0.1", "rayon", "wgpu", diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 8e16d106..c691ddaf 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -8,9 +8,14 @@ //! interior effects blend with the layer's mode. Layer opacity applies to //! the whole stack; fill opacity only to the layer's own pixels. //! -//! Shapes come from the layer's alpha (after its mask). Distances use an -//! exact Euclidean distance transform; soft falloffs use three box-blur -//! passes (≈ Gaussian) whose total support equals the effect size. +//! Interior effects are painted relative to the layer's shape and then take the shape's alpha +//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts +//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the +//! lower ones. Drop shadows are knocked out only through see-through fill. +//! +//! Shapes come from the layer's alpha (after its mask). Strokes measure distances with a +//! 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does); other effects +//! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs. use photocraft_color::blend::BlendMode; use photocraft_doc::{ @@ -177,6 +182,7 @@ fn edt_nearest(inside: &[bool], w: usize, h: usize) -> (Vec, Vec) { /// plus the index of the nearest `inside` pixel (0 on inside pixels). Photoshop's effect /// distances follow this metric: its stroke corners measure 3.236 = √5 + 1 at offset (1, 3) /// and 3.650 = √5 + √2 at (2, 3), where the exact distances are 3.162 and 3.606. +#[cfg(test)] fn chamfer_nearest(inside: &[bool], w: usize, h: usize) -> (Vec, Vec) { chamfer_from(inside.iter().map(|&b| if b { 0.0 } else { CHAMFER_INF }).collect(), w, h) } @@ -193,7 +199,7 @@ fn chamfer_from(mut d: Vec, w: usize, h: usize) -> (Vec, Vec) { const FWD: [(i64, i64, f32); 8] = [(-1, 0, 1.0), (-1, -1, S2), (0, -1, 1.0), (1, -1, S2), (-1, -2, S5), (1, -2, S5), (-2, -1, S5), (2, -1, S5)]; let mut near: Vec = (0..w * h).collect(); let (wi, hi) = (w as i64, h as i64); - let mut relax = |x: i64, y: i64, sign: i64, d: &mut Vec, near: &mut Vec| { + let relax = |x: i64, y: i64, sign: i64, d: &mut Vec, near: &mut Vec| { let i = (y * wi + x) as usize; let mut best = d[i]; let mut bn = near[i]; @@ -242,13 +248,6 @@ enum Metric { Chamfer, } -fn nearest(inside: &[bool], w: usize, h: usize, m: Metric) -> (Vec, Vec) { - match m { - Metric::Euclidean => edt_nearest(inside, w, h), - Metric::Chamfer => chamfer_nearest(inside, w, h), - } -} - fn dist_outside_by(s: &Map, m: Metric) -> Vec { if m == Metric::Chamfer { // Seeds start at their sub-pixel edge offset (1 − coverage). @@ -257,7 +256,7 @@ fn dist_outside_by(s: &Map, m: Metric) -> Vec { return d.iter().zip(&s.v).map(|(d, &a)| if a > INSIDE_EPS { -0.5 } else { d - 0.5 }).collect(); } let inside: Vec = s.v.iter().map(|&a| a > INSIDE_EPS).collect(); - let (d, near) = nearest(&inside, s.w, s.h, m); + let (d, near) = edt_nearest(&inside, s.w, s.h); (0..d.len()).map(|i| if inside[i] { -0.5 } else { d[i] - 0.5 + (1.0 - s.v[near[i]].min(1.0)) }).collect() } @@ -290,7 +289,7 @@ fn dist_inside_by(s: &Map, m: Metric) -> Vec { return d.iter().zip(&s.v).map(|(d, &a)| if a <= INSIDE_EPS { -0.5 } else { d - 0.5 }).collect(); } let outside: Vec = s.v.iter().map(|&a| a <= INSIDE_EPS).collect(); - let (d, _) = nearest(&outside, s.w, s.h, m); + let (d, _) = edt_nearest(&outside, s.w, s.h); d.iter().zip(&outside).map(|(d, o)| if *o { -0.5 } else { d - 0.5 }).collect() } @@ -752,8 +751,12 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu if let Effect::DropShadow(s) = e { let mut m = fx(i, 0); if s.knocks_out { + // The layer hides the shadow beneath it only where its fill is see-through: at + // 100 % fill the layer covers it anyway (and anti-aliased edges are not + // attenuated twice), at 0 % the shape shows the bare backdrop. + let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0); for (v, a) in m.v.iter_mut().zip(&shape.v) { - *v *= 1.0 - a; + *v *= 1.0 - a * see_through; } } paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity); @@ -849,28 +852,55 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu } let vdout = maps.vdout.as_ref().filter(|_| vector_shape && !strokes.is_empty()).map(|v| maps.crop_vec(v, big, FAR)); // Outside parts lie beneath the layer and blend onto the backdrop with their own mode. A - // higher stroke knocks out the ones below it (Photoshop: a Multiply stroke listed above a - // wider Normal stroke multiplies the backdrop, not the lower stroke). - let mut cover = vec![0.0f32; w * h]; - for st in strokes.iter().copied() { - let (_, out_w) = widths(st); - if out_w <= 0.0 { - continue; + // higher stroke covers the ones below it (Photoshop: a Multiply stroke listed above a wider + // Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's share is its + // coverage × opacity not yet taken by strokes above; for Normal strokes this equals stacking + // them top over bottom. + if strokes.iter().any(|st| widths(st).1 > 0.0) { + let base = work.clone(); + let mut acc = vec![[0.0f32; 4]; w * h]; // premultiplied Σ share·blended + let mut cover = vec![0.0f32; w * h]; + for st in strokes.iter().copied() { + let (_, out_w) = widths(st); + if out_w <= 0.0 { + continue; + } + // Shape layers: Photoshop strokes the vector outline, estimated from + // local coverage; the stroke never shows through the shape's pixels. + let d = vdout.as_ref().unwrap_or(&dout); + let mut share = vec![0.0f32; w * h]; + for (i, sh) in share.iter_mut().enumerate() { + let k = if inside(shape.v[i]) { + if vector_shape { 0.0 } else { 1.0 } + } else { + (out_w + 0.5 - d[i]).clamp(0.0, 1.0) + }; + *sh = k * st.common.opacity * (1.0 - cover[i]); + cover[i] += *sh; + } + // The stroke blended over the backdrop at full coverage. + let mut blended = base.clone(); + let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() }; + paint_fx(&mut blended, &ones, &st.paint, sb, anchor, big, st.common.blend, 1.0, patterns); + for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) { + if *s > 0.0 { + for c in 0..3 { + a[c] += s * b[c] * b[3]; + } + a[3] += s * b[3]; + } + } } - // Shape layers: Photoshop strokes the vector outline, estimated from - // local coverage; the stroke never shows through the shape's pixels. - let d = vdout.as_ref().unwrap_or(&dout); - let mut m = Map::new(w, h, 0.0); - for (((mv, a), dv), c) in m.v.iter_mut().zip(&shape.v).zip(d).zip(cover.iter_mut()) { - let k = if inside(*a) { - if vector_shape { 0.0 } else { 1.0 } - } else { - (out_w + 0.5 - dv).clamp(0.0, 1.0) - }; - *mv = k * (1.0 - *c); - *c += *mv; + for ((p, a), c) in work.px.iter_mut().zip(&acc).zip(&cover) { + if *c <= 0.0 { + continue; + } + let k = 1.0 - c.min(1.0); + let alpha = p[3] * k + a[3]; + if alpha > 0.0 { + *p = [(p[0] * p[3] * k + a[0]) / alpha, (p[1] * p[3] * k + a[1]) / alpha, (p[2] * p[3] * k + a[2]) / alpha, alpha.min(1.0)]; + } } - paint_fx(&mut work, &m, &st.paint, sb, anchor, big, st.common.blend, st.common.opacity, patterns); } for (i, e) in rev() { if let Effect::BevelEmboss(b) = e @@ -1006,6 +1036,21 @@ mod tests { assert!(rev > 0.99); } + #[test] + fn chamfer_uses_euclidean_step_lengths() { + let (w, h) = (9, 9); + let mut inside = vec![false; w * h]; + inside[0] = true; + let (d, near) = chamfer_nearest(&inside, w, h); + let at = |x: usize, y: usize| d[y * w + x]; + assert!((at(3, 0) - 3.0).abs() < 1e-5); + assert!((at(2, 2) - 2.0 * std::f32::consts::SQRT_2).abs() < 1e-5); + assert!((at(1, 2) - 5f32.sqrt()).abs() < 1e-5); + assert!((at(1, 3) - (5f32.sqrt() + 1.0)).abs() < 1e-5, "knight + straight, not √10"); + assert!((at(2, 3) - (5f32.sqrt() + std::f32::consts::SQRT_2)).abs() < 1e-5); + assert!(near.iter().all(|&n| n == 0)); + } + #[test] fn offsets_follow_light() { let (dx, dy) = offset(120.0, 10.0); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ae276649..4b9869b6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -660,3 +660,99 @@ fn effect_maps_are_cached_and_invalidated_by_pixel_changes() { } assert_ne!(first.px, again.px); } + +// ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ---------- + +#[test] +fn stroke_corners_follow_the_5x5_chamfer_metric() { + // Square 10..30, outside stroke 3: pixel (9, 7) is offset (1, 3) from the corner pixel. + // Photoshop measures √5 + 1 = 3.236 there (exact distance 3.162): coverage 3 + 1 − 3.236. + let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]); + let a = 1.0 - px(&d, 9, 7)[0]; // blue stroke over white: red channel drops by coverage + assert!((a - 0.764).abs() < 0.01, "{a}"); + // (2, 2) stays Euclidean (two diagonal steps). + let a = 1.0 - px(&d, 8, 8)[0]; + assert!((a - 1.0).abs() < 0.01, "{a}"); +} + +#[test] +fn inside_stroke_respects_partial_edge_coverage() { + // A 25 %-alpha edge column puts the edge 0.75 px further out: a 3 px inside stroke then + // reaches 75 % into the fourth column, and the edge column takes the stroke colour at 25 %. + let mut d = doc_white(40, 40); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 11, 30), &[1.0, 0.0, 0.0, 0.25]); + l.effects.items = vec![stroke(3.0, StrokePosition::Inside)]; + d.layers.push(l); + let edge = px(&d, 10, 20); + assert!(close4(edge, [0.75, 0.75, 1.0, 1.0]), "edge = blue stroke at 25 % over white: {edge:?}"); + let p = px(&d, 13, 20); + assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}"); +} + +#[test] +fn interior_effects_keep_the_layer_alpha() { + // A colour overlay replaces a half-transparent pixel's colour without adding coverage. + let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 0.5]); + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }]; + d.layers.push(l); + assert!(close4(px(&d, 20, 20), [0.0, 1.0, 0.0, 0.5]), "{:?}", px(&d, 20, 20)); +} + +#[test] +fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() { + // Multiply yellow (3 px) listed above a Normal blue (8 px): the inner ring multiplies the + // white backdrop (stays yellow), the outer ring is blue. + let yellow = Effect::Stroke(StrokeFx { + common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0), + size: 3.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), + }); + let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]); + assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20)); + assert!(close4(px(&d, 4, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 4, 20)); +} + +#[test] +fn shadow_knockout_needs_see_through_fill() { + // At 100 % fill an anti-aliased edge is not attenuated twice: the result equals no knockout. + let mut base = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8); + base.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4])); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 11, 30), &[1.0, 0.0, 0.0, 0.5]); + l.effects.items = vec![Effect::DropShadow(Shadow { distance: 0.0, spread: 1.0, size: 4.0, ..shadow(0.0, 0.0) })]; + base.layers.push(l); + let on = px(&base, 10, 20); + if let Effect::DropShadow(s) = &mut base.layers[1].effects.items[0] { + s.knocks_out = false; + } + assert!(close4(on, px(&base, 10, 20)), "{on:?}"); +} + +#[test] +fn linked_pattern_overlay_anchors_at_the_effects_reference_point() { + use photocraft_doc::pattern::Pattern; + // 2 × 1 tile: red, blue. Anchored at x = 11 (reference point), x = 11 is red, 12 blue. + let mut tile = photocraft_raster::Surface::new(PixelFormat::RGBA8); + tile.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + tile.fill_rect(Rect::new(1, 0, 2, 1), &[0.0, 0.0, 1.0, 1.0]); + let pat = Pattern::new("rb", tile, 2, 1); + let overlay = Effect::PatternOverlay { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + name: "rb".into(), + id: pat.id.clone(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }; + let mut d = fx_doc(vec![overlay]); + d.patterns.push(pat); + // Without a reference point the layer's top-left (10) anchors the tiling. + assert!(close4(px(&d, 10, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 10, 20)); + d.layers[1].effects.reference = Some((11.0, 0.0)); + assert!(close4(px(&d, 11, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 11, 20)); + assert!(close4(px(&d, 12, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 12, 20)); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2850dd54..4411679f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -420,3 +420,18 @@ fn layer_locks_are_set_and_enforced() { let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); assert!(l.locks.position && !l.locks.pixels); } + +#[test] +fn moving_a_layer_moves_its_effects_reference_point() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("ref", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().effects.reference = Some((-201.0, 9.0)); + Ok(()) + }) + .unwrap(); + s.execute("layer.translate", json!({"dx": 10, "dy": 5})).unwrap(); + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + assert_eq!(l.effects.reference, Some((-191.0, 14.0))); +} diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 9cd2c144..dc08bcd3 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -24,3 +24,5 @@ rayon = { workspace = true } [dev-dependencies] wgpu = { version = "30" } pollster = "1" +# Shape layers in the effect parity tests. +photocraft-vector = { workspace = true } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index fa789be8..5650dce0 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -38,12 +38,12 @@ const F_MASK_TEX: u32 = 2u; // mask pixels live in `mask_tex` const F_TEX: u32 = 4u; // layer pixels live in `layer_tex` const F_GRADIENT: u32 = 8u; // gradient fill (stops in `lut_tex`) const F_KNOCKOUT: u32 = 16u; // effect paint: the layer knocks out the coverage (drop shadow) -const F_UNDER: u32 = 32u; // effect paint: slide beneath A (outside stroke) const F_VECTOR: u32 = 64u; // effect paint: shape layer (outside stroke never inside) const F_ATOP: u32 = 128u; // effect merge: clipped layer over an opaque base const F_GATE: u32 = 256u; // effect paint: coverage only inside the layer's shape const F_REL: u32 = 512u; // effect paint: coverage relative to the layer's alpha const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band +const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet @group(0) @binding(0) var chunk: Chunk; @group(0) @binding(1) var op: Op; @@ -675,9 +675,10 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { } // effects::paint: composite `colour × coverage × opacity` into A. B is the layer (its alpha `a` -// is the effect shape). Coverage `kind`: 0 the effect map, 1 full; then F_KNOCKOUT m × (1 − a), +// is the effect shape). Coverage `kind`: 0 the effect map, 1 full; then F_KNOCKOUT m × (1 − a k) +// (k = `p4.w`), // F_GATE inside ? m : 0, F_REL inside ? min(m / a, 1) : 0, F_STROKE_OUT inside ? (vector ? 0 : 1) -// : m. F_UNDER paints beneath A (outside strokes). +// : m. @fragment fn fs_fxpaint(in: VOut) -> @location(0) vec4 { let p = local(in.pos); @@ -687,7 +688,7 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4 { let inside_shape = a > INSIDE_EPS; var m = 1.0; if (op.kind == 0) { m = map_value(d, op.p2.w); } - if ((op.flags & F_KNOCKOUT) != 0u) { m = m * (1.0 - a); } + if ((op.flags & F_KNOCKOUT) != 0u) { m = m * (1.0 - a * op.p4.w); } if ((op.flags & F_GATE) != 0u && !inside_shape) { m = 0.0; } if ((op.flags & F_REL) != 0u) { if (inside_shape) { m = min(m / a, 1.0); } else { m = 0.0; } @@ -696,20 +697,55 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4 { m = select(1.0, 0.0, (op.flags & F_VECTOR) != 0u); } let k = m * op.opacity; - if ((op.flags & F_UNDER) != 0u) { - // Painted into a transparent buffer, then A goes over it. - var under = vec4(0.0); - if (k > 0.0) { - let c = fx_color(d); - under = composite(M_NORMAL, vec4(0.0), vec4(c.rgb, c.a * k), 1.0); - } - return composite(M_NORMAL, under, dst, 1.0); - } if (k <= 0.0) { return dst; } let c = fx_color(d); return composite(op.mode, dst, vec4(c.rgb, c.a * k), 1.0); } +// Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in +// `.r`, D (`layer_tex`) = accumulated premultiplied colour). Each stroke takes its band × opacity +// not yet covered above it; `kind` 0 accumulates that share of the stroke blended over A at full +// coverage, `kind` 1 the coverage. +@fragment +fn fs_fxstroke(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + let a = textureLoad(tex_b, p, 0).a; + var k = map_value(d, 0.0); + if (a > INSIDE_EPS) { k = select(1.0, 0.0, (op.flags & F_VECTOR) != 0u); } + let first = (op.flags & F_FIRST) != 0u; + var cover = 0.0; + if (!first) { cover = textureLoad(tex_c, p, 0).r; } + let share = k * op.opacity * (1.0 - cover); + if (op.kind == 1) { return vec4(cover + share, 0.0, 0.0, 0.0); } + var acc = vec4(0.0); + if (!first) { acc = textureLoad(layer_tex, p, 0); } + if (share > 0.0) { + let base = textureLoad(tex_a, p, 0); + var bl = base; + if (i32(op.p2.z) != 3) { + let c = fx_color(d); + bl = composite(op.mode, base, vec4(c.rgb, c.a), 1.0); + } + acc += share * vec4(bl.rgb * bl.a, bl.a); + } + return acc; +} + +// Resolve outside strokes: A = exterior result, B = accumulated colour, C = coverage. +@fragment +fn fs_fxstrokeend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let w = textureLoad(tex_a, p, 0); + let cover = textureLoad(tex_c, p, 0).r; + if (cover <= 0.0) { return w; } + let acc = textureLoad(tex_b, p, 0); + let k = 1.0 - min(cover, 1.0); + let alpha = w.a * k + acc.a; + if (alpha <= 0.0) { return w; } + return vec4((w.rgb * w.a * k + acc.rgb) / alpha, min(alpha, 1.0)); +} + fn mix_premul(a: vec4, b: vec4, k: f32) -> vec4 { let alpha = a.a + (b.a - a.a) * k; if (alpha <= 0.0) { return vec4(0.0); } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 73de9ae3..c1b61a9f 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -132,24 +132,24 @@ impl MapProgram { let mut count = 0; let mut out = vec![None; n]; let mut held: Vec<(usize, usize)> = Vec::new(); - for i in 0..n { + for (i, (stage, slot)) in self.stages.iter().zip(out.iter_mut()).enumerate() { // Release temporaries whose last reader was before this stage. - held.retain(|&(stage, t)| { - if last_use[stage] < i { + held.retain(|&(st, t)| { + if last_use[st] < i { free.push(t); false } else { true } }); - if self.stages[i].out.is_some() { + if stage.out.is_some() { continue; } let t = free.pop().unwrap_or_else(|| { count += 1; count - 1 }); - out[i] = Some(t); + *slot = Some(t); held.push((i, t)); } (out, count) @@ -213,7 +213,7 @@ impl B { /// Distance up to which a field must be exact for a band / dilation of width `w` /// (`clamp(w + 0.5 - d)`, with `d` up to 1.5 px beyond the pixel distance). fn reach_for(w: f32) -> i32 { - (w.max(0.0).min(photocraft_compose::effects::MAX_REACH)).ceil() as i32 + 2 + w.clamp(0.0, photocraft_compose::effects::MAX_REACH).ceil() as i32 + 2 } /// `compose::effects::blur`'s kernel: (radius, normalised weights), or None below sigma 0.2. @@ -478,11 +478,10 @@ pub(crate) fn shape_sources(layer: &Layer) -> (Option<&Surface>, Option<&Surface (content, mask) } -/// Everything a non-group layer's shape depends on except tile contents. -pub(crate) fn shape_key(layer: &Layer, canvas: Rect, region: Rect) -> u64 { +/// Everything a non-group layer's shape depends on except tile contents and position. +pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); layer.id.0.hash(&mut h); - (region.x0, region.y0, region.x1, region.y1).hash(&mut h); let (content, mask) = shape_sources(layer); match &layer.content { LayerContent::Fill(f) => { @@ -503,7 +502,7 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect, region: Rect) -> u64 { } /// Deep identity of a group (children, their pixels by tile pointer, settings). -pub(crate) fn group_key(layer: &Layer, region: Rect, light: &GlobalLight) -> u64 { +pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 { fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher) { format!("{:?}{:?}", s.format(), s.default_pixel()).hash(h); for (c, t) in s.tiles() { @@ -540,7 +539,7 @@ pub(crate) fn group_key(layer: &Layer, region: Rect, light: &GlobalLight) -> u64 h.write_u8(0xfe); } let mut h = std::collections::hash_map::DefaultHasher::new(); - (region.x0, region.y0, region.x1, region.y1, light.angle.to_bits(), light.altitude.to_bits()).hash(&mut h); + (light.angle.to_bits(), light.altitude.to_bits()).hash(&mut h); match &layer.content { LayerContent::Group(g) => { for c in &g.children { diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 933aa800..0108191b 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -331,13 +331,13 @@ fn uniform_entry(binding: u32, size: u64) -> wgpu::BindGroupLayoutEntry { /// The compositor's WGSL source (exposed for validation in tests). pub const SHADER: &str = include_str!("compose.wgsl"); -/// Pass-level resources resolved for one plan. -struct Bound<'p> { +/// Pass-level resources resolved for one plan: residents (texture, mask), effect maps (view and +/// region) and pattern textures per pass. +struct Bound { views: Vec<(Option, Option)>, keys: Vec<(LayerId, Role)>, maps: Vec>, patterns: Vec>, - _plan: std::marker::PhantomData<&'p ()>, } impl Compositor { @@ -491,7 +491,7 @@ impl Compositor { } /// Bring every surface `plan` samples up to date and resolve its effect maps and patterns. - fn bind_plan<'p>(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, doc: DocId, canvas: Rect, plan: &Plan<'p>, stats: &mut Stats) -> Bound<'p> { + fn bind_plan(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, doc: DocId, canvas: Rect, plan: &Plan<'_>, stats: &mut Stats) -> Bound { let mut views: Vec<(Option, Option)> = Vec::with_capacity(plan.passes.len()); let mut keys: Vec<(LayerId, Role)> = Vec::new(); let mut maps = Vec::with_capacity(plan.passes.len()); @@ -513,7 +513,7 @@ impl Compositor { })); patterns.push(p.pattern.map(|pat| self.pattern_view(device, queue, pat))); } - Bound { views, keys, maps, patterns, _plan: std::marker::PhantomData } + Bound { views, keys, maps, patterns } } /// Run `plan` over `chunks` (document rects, at most CHUNK square), handing each finished @@ -568,7 +568,7 @@ impl Compositor { } let slot = |s: Option| s.map_or(dummy, |s| &self.pool[s as usize].1); let (tex, mask) = &bound.views[i]; - let tv = tex.map_or(dummy, |(k, _)| resident_views[k]); + let tv = tex.map_or(slot(p.d), |(k, _)| resident_views[k]); let mv = mask.map_or(dummy, |(k, _)| resident_views[k]); let lv = luts[i].as_ref().map_or(dummy, |(_, v)| v); let map = bound.maps[i].as_ref().map_or(dummy, |(v, _)| v); @@ -724,7 +724,7 @@ impl Compositor { let frame = self.frame; for (t, used) in &mut self.temps { let s = t.texture.size(); - if s.width == w && s.height == h && !taken.iter().any(|v| *v == t.view) { + if s.width == w && s.height == h && !taken.contains(&t.view) { *used = frame; return t.view.clone(); } @@ -741,14 +741,18 @@ impl Compositor { let region = f.region; let canvas = doc.bounds(); let is_group = matches!(layer.content, LayerContent::Group(_)); - let shape_key = if is_group { fx::group_key(layer, region, &doc.global_light) } else { fx::shape_key(layer, canvas, region) }; + let shape_key = if is_group { fx::group_key(layer, &doc.global_light) } else { fx::shape_key(layer, canvas) }; let (content_src, mask_src) = if is_group { (None, None) } else { fx::shape_sources(layer) }; let frame = self.frame; + let (rw, rh) = (region.width(), region.height()); - // What changed: everything (new region / settings), some tiles, or nothing. + // What changed: everything (new settings / size), the position only, some tiles, or + // nothing. let mut damage = Rect::EMPTY; - let reuse = self.fx.get(&layer.id).is_some_and(|e| e.region == region && e.shape_key == shape_key); - if reuse { + let prev = self.fx.get(&layer.id).filter(|e| e.shape_key == shape_key).map(|e| e.region); + let mut rebuild = true; + if prev == Some(region) { + rebuild = false; let e = &self.fx[&layer.id]; for (old, cur) in e.tiles.iter().zip([content_src, mask_src]) { let d = match (old, cur) { @@ -760,9 +764,22 @@ impl Compositor { damage = if damage.is_empty() { d } else { damage.union(&d) }; } damage = damage.intersect(®ion); - } else { + } else if let Some(old) = prev + && (old.width(), old.height()) == (rw, rh) + && !region.is_empty() + { + // Moved by whole pixels: maps are computed relative to the region, so if the shape + // moved unchanged with it, every map is still exact. + let v = fx::shape(doc, layer, region); + let e = self.fx.get_mut(&layer.id).expect("entry"); + if v == e.shape_cpu { + e.region = region; + rebuild = false; + } + } + if rebuild { let shape = Tex::map(device, "pc_fx_shape", region, MAP32); - let n = region.width() as usize * region.height() as usize; + let n = rw as usize * rh as usize; self.fx.insert(layer.id, FxEntry { doc: doc.id, region, shape_key, tiles: [None, None], _pin: is_group.then(|| layer.clone()), shape_cpu: vec![0.0; n], shape, fields: HashMap::new(), progs: Vec::new(), last_used: frame }); damage = region; } @@ -774,15 +791,11 @@ impl Compositor { return; } - let t_trace = std::time::Instant::now(); + let t_trace = web_time_now(); // Shape over the damage (compose's own alpha). if !damage.is_empty() { stats.fx_shapes += 1; - let ts = std::time::Instant::now(); let v = fx::shape(doc, layer, damage); - if std::env::var_os("PHOTOCRAFT_FX_TRACE").is_some() { - eprintln!(" shape {}x{}: {:.2} ms", damage.width(), damage.height(), ts.elapsed().as_secs_f64() * 1000.0); - } fx::paste(&mut e.shape_cpu, region, damage, &v); e.shape.write_r32(queue, region, damage, &v); } @@ -808,11 +821,7 @@ impl Compositor { _ => region, }; let reach = have.filter(|r| *r >= reach).unwrap_or(reach); - let tf = std::time::Instant::now(); let v = fx::field(kind, reach, &e.shape_cpu, region, out); - if std::env::var_os("PHOTOCRAFT_FX_TRACE").is_some() { - eprintln!(" field {kind:?} reach {reach} over {}x{}: {:.2} ms", out.width(), out.height(), tf.elapsed().as_secs_f64() * 1000.0); - } if have.is_none_or(|r| r < reach) { e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); } @@ -820,8 +829,8 @@ impl Compositor { stats.fx_pixels += out.width() as u64 * out.height() as u64; } - if std::env::var_os("PHOTOCRAFT_FX_TRACE").is_some() && !damage.is_empty() { - eprintln!("fx `{}`: damage {:?} shape+fields {:.2} ms", layer.name, damage, t_trace.elapsed().as_secs_f64() * 1000.0); + if let Some(t) = t_trace.filter(|_| !damage.is_empty()) { + eprintln!("effect maps of `{}`: shape + distance fields over {damage:?} in {:.2} ms (CPU)", layer.name, t.elapsed().as_secs_f64() * 1000.0); } e.progs.truncate(progs.len()); while e.progs.len() < progs.len() { @@ -899,6 +908,18 @@ impl Compositor { } } +/// A start time when `PHOTOCRAFT_FX_TRACE=1` (it prints the CPU time of effect shapes and +/// distance fields); never on wasm. +fn web_time_now() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + static T: std::sync::OnceLock = std::sync::OnceLock::new(); + T.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_TRACE").is_some()).then(std::time::Instant::now) + } + #[cfg(target_arch = "wasm32")] + None +} + /// The uniform record of a pass. fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { let mut flags = p.flags; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 50a978f2..a959a05f 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -42,6 +42,11 @@ pub enum Kernel { FxPaint, /// Layer + interior effects over the exterior result, then layer opacity against the backdrop. FxMerge, + /// Outside strokes: accumulate one stroke's share (`kind` 0, premultiplied colour) or its + /// coverage (`kind` 1). + FxStroke, + /// Outside strokes: resolve the accumulated strokes over the exterior result. + FxStrokeEnd, /// Copy slot A into `dst` (an existing slot) over the pass clip (no draw). CopyRect, /// Copy all of slot A into the fresh slot `dst` (no draw). @@ -72,6 +77,8 @@ impl Kernel { Kernel::FxInit => "fs_fxinit", Kernel::FxPaint => "fs_fxpaint", Kernel::FxMerge => "fs_fxmerge", + Kernel::FxStroke => "fs_fxstroke", + Kernel::FxStrokeEnd => "fs_fxstrokeend", Kernel::MShift => "fs_mshift", Kernel::MDilate => "fs_mdilate", Kernel::MBlur => "fs_mblur", @@ -99,7 +106,7 @@ impl Kernel { } /// Every kernel with a pipeline. - pub const DRAWN: [Kernel; 18] = [ + pub const DRAWN: [Kernel; 20] = [ Kernel::Content, Kernel::Mask, Kernel::Blend, @@ -110,6 +117,8 @@ impl Kernel { Kernel::FxInit, Kernel::FxPaint, Kernel::FxMerge, + Kernel::FxStroke, + Kernel::FxStrokeEnd, Kernel::MShift, Kernel::MDilate, Kernel::MBlur, @@ -151,6 +160,8 @@ pub struct Pass<'a> { pub a: Option, pub b: Option, pub c: Option, + /// A fourth slot, bound in place of `tex` (passes without layer pixels). + pub d: Option, pub mode: BlendMode, pub opacity: f32, /// Layer pixels (raster / text / shape / smart cache / fill cache). @@ -194,6 +205,7 @@ impl<'a> Pass<'a> { a: None, b: None, c: None, + d: None, mode: BlendMode::Normal, opacity: 1.0, tex: None, @@ -285,11 +297,9 @@ enum Cov { /// Shader flags for effect passes (keep in sync with compose.wgsl). Coverage `m` of a paint, with /// `a` the layer's alpha and `inside` = `a > 0.5/255`: -/// knockout `m × (1 − a)`; gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`; +/// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`; /// stroke-out `inside ? (vector ? 0 : 1) : m`. pub const F_KNOCKOUT: u32 = 16; -/// Paint beneath A instead of over it (outside strokes). -pub const F_UNDER: u32 = 32; /// Shape layer (outside strokes never show inside it). pub const F_VECTOR: u32 = 64; /// Merge onto an opaque clipping base, keeping its alpha. @@ -297,6 +307,8 @@ pub const F_ATOP: u32 = 128; pub const F_GATE: u32 = 256; pub const F_REL: u32 = 512; pub const F_STROKE_OUT: u32 = 1024; +/// First outside stroke (no accumulated share yet). +pub const F_FIRST: u32 = 2048; /// `FxInit` kinds: A at `opacity` × alpha; an opaque copy of A; A's colour with alpha `opacity` /// inside B's shape; A with alpha × B's alpha. @@ -337,9 +349,9 @@ impl<'a> Planner<'a> { fn emit(&mut self, mut pass: Pass<'a>) -> Slot { let dst = self.alloc(); pass.dst = dst; - let (a, b, c) = (pass.a, pass.b, pass.c); + let (a, b, c, d) = (pass.a, pass.b, pass.c, pass.d); self.passes.push(pass); - for s in [a, b, c].into_iter().flatten() { + for s in [a, b, c, d].into_iter().flatten() { self.release(s); } dst @@ -618,8 +630,10 @@ impl<'a> Planner<'a> { }; for &(i, e) in &rev { if let Effect::DropShadow(s) = e { + // The layer hides the shadow beneath it where its fill is see-through. let flags = if s.knocks_out { F_KNOCKOUT } else { 0 }; - w = self.paint(w, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, flags, clip, sb); + let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0); + w = self.paint_k(w, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, flags, clip, sb, see_through); } } for &(i, e) in &rev { @@ -688,15 +702,39 @@ impl<'a> Planner<'a> { // The shape's own alpha. let c = self.retain(content); l = init(self, l, Some(c), INIT_ALPHA, 1.0); - // Outside stroke parts slide beneath the layer (top instance first). - for (i, e) in items.iter().copied().enumerate() { - if let Effect::Stroke(st) = e - && stroke_widths(st).1 > 0.0 - { + // Outside stroke parts lie beneath the layer and blend onto the exterior result with their + // own mode; a higher stroke covers the ones below it (each takes its coverage × opacity + // not yet taken above it, blended over the result as it was before the strokes). + let outs: Vec<(usize, &photocraft_doc::StrokeFx)> = items.iter().copied().enumerate().filter_map(|(i, e)| if let Effect::Stroke(st) = e { Some((i, st)) } else { None }).filter(|(_, st)| stroke_widths(st).1 > 0.0).collect(); + if !outs.is_empty() { + let (mut acc, mut cover): (Option, Option) = (None, None); + for (n, &(i, st)) in outs.iter().enumerate() { let paint = self.fx_paint(&st.paint, anchor); - let flags = F_UNDER | F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 }; - l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &paint, BlendMode::Normal, st.common.opacity, flags, clip, sb); + let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if n == 0 { F_FIRST } else { 0 }; + // Share × blended colour, then coverage (both read the coverage so far). + let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &paint, st.common.blend, st.common.opacity, flags, clip, sb); + if matches!(paint, Paint::None) { + // A missing pattern paints nothing: the stroke's share keeps the result as is. + p.params[2][2] = 3.0; + } + p.adjust_kind = 0; + p.map = Some(map(i, 0)); + p.c = cover.map(|c| self.retain(c)); + p.d = acc; + let a = self.emit(p); + let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), BlendMode::Normal, st.common.opacity, flags, clip, sb); + p.adjust_kind = 1; + p.map = Some(map(i, 0)); + p.c = cover; + cover = Some(self.emit(p)); + acc = Some(a); } + let mut p = Pass::new(Kernel::FxStrokeEnd, 0); + p.a = Some(w); + p.b = acc; + p.c = cover; + p.clip = Some(clip); + w = self.emit(p); } for &(i, e) in &rev { if let Effect::BevelEmboss(b) = e @@ -748,28 +786,22 @@ impl<'a> Planner<'a> { } } - /// One effect paint into `dst` (consumed); reads the layer's alpha from `content` (kept). + /// An effect pass reading `dst` (A, retained) and the layer `content` (B, retained) with its + /// paint set up. #[allow(clippy::too_many_arguments)] - fn paint(&mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Slot { - if matches!(paint, Paint::None) { - // Missing pattern: compose paints nothing. - return dst; - } - let mut p = Pass::new(Kernel::FxPaint, 0); - p.a = Some(dst); + fn fx_pass(&mut self, dst: Slot, content: Slot, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Pass<'a> { + self.fx_pass_kernel(Kernel::FxPaint, dst, content, paint, blend, opacity, flags, clip, sb) + } + + #[allow(clippy::too_many_arguments)] + fn fx_pass_kernel(&mut self, kernel: Kernel, dst: Slot, content: Slot, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Pass<'a> { + let mut p = Pass::new(kernel, 0); + p.a = Some(self.retain(dst)); p.b = Some(self.retain(content)); p.mode = blend; p.opacity = opacity; p.flags = flags; p.clip = Some(clip); - match cov { - Cov::Map(m, outside) => { - p.adjust_kind = 0; - p.map = Some(m); - p.params[2][3] = outside; - } - Cov::One => p.adjust_kind = 1, - } match paint { Paint::None => {} Paint::Color(c) => p.color = [c[0], c[1], c[2], 1.0], @@ -795,6 +827,33 @@ impl<'a> Planner<'a> { p.pattern = Some(pat); } } + p + } + + /// One effect paint into `dst` (consumed); reads the layer's alpha from `content` (kept). + #[allow(clippy::too_many_arguments)] + fn paint(&mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Slot { + self.paint_k(dst, content, cov, paint, blend, opacity, flags, clip, sb, 1.0) + } + + /// [`Self::paint`] with the knockout strength (`F_KNOCKOUT`: coverage × (1 − alpha × k)). + #[allow(clippy::too_many_arguments)] + fn paint_k(&mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect, knockout: f32) -> Slot { + if matches!(paint, Paint::None) { + // Missing pattern: compose paints nothing. + return dst; + } + let mut p = self.fx_pass(dst, content, paint, blend, opacity, flags, clip, sb); + self.release(dst); + match cov { + Cov::Map(m, outside) => { + p.adjust_kind = 0; + p.map = Some(m); + p.params[2][3] = outside; + } + Cov::One => p.adjust_kind = 1, + } + p.extra[3] = knockout; self.emit(p) } } diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 0e953425..35ae977a 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -451,6 +451,14 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { ("gradient overlay", vec![Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient { style: GradientStyle::Radial, ..gradient() }, dither: false }]), ("pattern overlay", vec![Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 0.9), name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (2.0, 1.0) }]), ("pattern overlay scaled rotated", vec![Effect::PatternOverlay { common: FxCommon::new(BlendMode::Screen, 0.8), name: pat.name.clone(), id: pat.id.clone(), scale: 1.7, angle: 30.0, link: false, phase: (0.0, 0.0) }]), + ( + "missing pattern", + vec![ + Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: "nope".into(), id: "nope".into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }, + Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Pattern { name: "nope".into(), id: "nope".into(), scale: 1.0 })), + Effect::Stroke(stroke(6.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))), + ], + ), ( "multiple instances", vec![ @@ -637,7 +645,7 @@ fn layer_effects_update_incrementally() { } let s = fx_check(&mut g, &d, "drop shadow spread"); assert_eq!(s.fx_programs, 1, "{s:?}"); - // Moving the layer (new region) rebuilds everything. + // 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(); @@ -646,5 +654,32 @@ fn layer_effects_update_incrementally() { dst }; *d.layers[1].surface_mut().unwrap() = moved; - fx_check(&mut g, &d, "moved"); + let s = fx_check(&mut g, &d, "moved"); + assert_eq!(s.fx_programs, 0, "{s:?}"); + // A change of shape with the move rebuilds. + d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(300, 300, 310, 310), &[1.0, 1.0, 1.0, 0.5]); + fx_check(&mut g, &d, "moved and painted"); +} + +#[test] +fn layer_effects_on_shape_layers() { + let Some(mut g) = gpu() else { return }; + use photocraft_doc::vector::{Path, ShapeLayer, Subpath}; + for alpha in [1.0, 0.55] { + let mut d = fx_doc(90, 70, SampleType::U8); + let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); + let mut fill = Color::rgb(0.3, 0.6, 0.9); + fill.alpha = alpha; + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + let mut l = Layer::new("shape", LayerContent::Shape(sh)); + l.effects.items = vec![ + Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))), + Effect::Stroke(stroke(4.0, StrokePosition::Center, FxPaint::Gradient(gradient()))), + Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0)), + Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 4.0, 1.0)), + ]; + d.layers.push(l); + fx_check(&mut g, &d, &format!("shape layer alpha {alpha}")); + } } diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index 1fe76b93..0bd5c3fc 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -5,6 +5,9 @@ //! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 png out.png # ours | Photoshop | diff heatmap //! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 col [x] # column samples //! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 row y x0 x1 # row samples +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 worst [n] # n worst pixels +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 grid x0 y0 x1 y1 [ch] # value grids +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 layerpx x y # each layer's pixel //! DUMP_FX=1 … # raw effects descriptors //! ``` fn main() { diff --git a/crates/io/examples/pattern_fit.rs b/crates/io/examples/pattern_fit.rs deleted file mode 100644 index 46552918..00000000 --- a/crates/io/examples/pattern_fit.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! TEMPORARY: transfer-curve fit for an adjustment layer vs Photoshop's merged image. -//! args: file.psd "layer name" x0 y0 x1 y1 -fn main() { - let a: Vec = std::env::args().collect(); - let bytes = std::fs::read(&a[1]).unwrap(); - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); - let imp = photocraft_io::import(&a[1], &bytes).unwrap(); - let mut doc = imp.document.clone(); - let merged = photocraft_io::merged_composite(&file).unwrap(); - let w = doc.size.width as usize; - let r: Vec = a[3..7].iter().map(|s| s.parse().unwrap()).collect(); - let id = doc.walk().into_iter().find(|t| t.2.name == a[2]).map(|t| t.2.id).expect("layer"); - doc.layer_mut(id).unwrap().visible = false; - let below = photocraft_compose::flatten(&doc).px; - let mut acc = vec![(0.0f64, 0usize); 256]; - for y in r[1]..r[3] { - for x in r[0]..r[2] { - let i = y * w + x; - let v = (below[i][0] * 255.0).round().clamp(0.0, 255.0) as usize; - acc[v].0 += f64::from(merged[i][0]) * 255.0; - acc[v].1 += 1; - } - } - let mut line = Vec::new(); - for (v, (s, n)) in acc.iter().enumerate() { - if *n > 0 { - line.push(format!("{v}:{:.1}", s / *n as f64)); - } - } - println!("{}", line.join(" ")); -} diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 7e42a320..24adb467 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -240,9 +240,10 @@ fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool) -> Vec { v.extend_from_slice(&((t.clamp(0.0, 1.0) * 4096.0).round() as u32).to_be_bytes()); v.extend_from_slice(&50u32.to_be_bytes()); put16(&mut v, 0); - for k in 0..4 { - put16(&mut v, if k < 3 { (c[k].clamp(0.0, 1.0) * 65535.0).round() as u16 } else { 0 }); + for x in c { + put16(&mut v, (x.clamp(0.0, 1.0) * 65535.0).round() as u16); } + put16(&mut v, 0); // fourth component put16(&mut v, 0); } put16(&mut v, 2); diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 63cb8cf8..e7c6bc74 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -352,3 +352,51 @@ fn clearing_effects_drops_stale_blocks() { let rec = f.layers().iter().find(|r| r.name() == back.layers[1].name).unwrap(); assert!(rec.block(b"lfx2").is_none()); } + +#[test] +fn link_groups_and_effects_reference_round_trip() { + use photocraft_doc::{Document, Layer, LayerContent, Size}; + let mut d = Document::new("l", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let mut a = Layer::raster("a", fmt); + a.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + a.link_group = Some(7); + a.effects.reference = Some((-201.0, 47.5)); + let mut b = Layer::raster("b", fmt); + b.link_group = Some(7); + let mut inner = Layer::raster("inner", fmt); + inner.link_group = Some(3); + let g = Layer::group("g", vec![inner]); + d.layers = vec![a, b, g]; + let back = roundtrip(&d); + assert_eq!(back.layers[0].link_group, Some(7)); + assert_eq!(back.layers[1].link_group, Some(7)); + assert_eq!(back.layers[2].link_group, None); + let LayerContent::Group(gb) = &back.layers[2].content else { panic!() }; + assert_eq!(gb.children[0].link_group, Some(3)); + assert_eq!(back.layers[0].effects.reference, Some((-201.0, 47.5))); + assert_eq!(back.layers[1].effects.reference, None); + // Unchanged documents write the same resource bytes again. + let r1 = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + let f1 = PsdFile::from_bytes(&r1).unwrap(); + let res = |f: &PsdFile| f.resources.iter().find(|r| r.id == 1026).map(|r| r.data.clone()); + let f0 = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(res(&f1), res(&f0)); + // One u16 per record: a, b, divider, inner, group (bottom first). + assert_eq!(res(&f0).unwrap(), vec![0, 7, 0, 7, 0, 0, 0, 3, 0, 0]); + // Large engine ids are renumbered by first appearance. + let mut big = d.clone(); + big.layers[0].link_group = Some(1 << 40); + big.layers[1].link_group = Some(1 << 40); + let fb = PsdFile::from_bytes(&export(&big, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(res(&fb).unwrap(), vec![0, 1, 0, 1, 0, 0, 0, 2, 0, 0]); + // Unlinked documents write no 1026. + let mut none = d.clone(); + none.layers[0].link_group = None; + none.layers[1].link_group = None; + if let LayerContent::Group(g) = &mut none.layers[2].content { + g.children[0].link_group = None; + } + let fnone = PsdFile::from_bytes(&export(&none, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert!(res(&fnone).is_none()); +} diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index 3484bc34..cc02f582 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -135,18 +135,53 @@ fn cpu_time(doc: &Document, region: Rect) -> f64 { t.elapsed().as_secs_f64() * 1000.0 } -fn report(what: &str, region: Rect, cpu: Option, gpu: Result) { - let mp = region.width() as f64 * region.height() as f64 / 1e6; - let c = cpu.map_or("-".to_string(), |v| format!("{v:9.1} ms")); - let g = match gpu { - Ok(v) => format!("{v:9.1} ms"), - Err(e) => format!("unsupported ({e})"), - }; - println!("{what:<40} {mp:7.2} MP cpu {c:>12} gpu {g}"); +/// `--compare a.psd b.psd …`: GPU vs CPU max difference (premultiplied, /255) per file. +fn compare(files: &[String]) { + let Some(mut g) = gpu() else { return }; + let mut worst_all = 0.0f32; + for p in files { + let Ok(bytes) = std::fs::read(p) else { continue }; + let Ok(r) = photocraft_io::import(p, &bytes) else { + println!("{p}: import failed"); + continue; + }; + let doc = r.document; + let fx = doc.walk().iter().filter(|(_, _, l)| photocraft_compose::effects::has_effects(l)).count(); + let cpu = photocraft_compose::flatten(&doc); + match photocraft_gpu::render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()) { + Ok(out) => { + let mut worst = (0.0f32, 0usize); + for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() { + for k in 0..4 { + let (a, b) = if k == 3 { (c[3], o[3]) } else { (c[k] * c[3], o[k] * o[3]) }; + let d = (a - b).abs(); + if d > worst.0 || d.is_nan() { + worst = (if d.is_nan() { 9.0 } else { d }, i); + } + } + } + let w = doc.size.width as usize; + worst_all = worst_all.max(worst.0); + println!("{:<70} {fx:>2} fx layers max diff {:6.2}/255 at ({},{})", p, worst.0 * 255.0, worst.1 % w, worst.1 / w); + if std::env::var_os("FX_DUMP").is_some() && worst.0 > 1.0 / 255.0 { + println!(" cpu {:?} gpu {:?}", cpu.px[worst.1], out[worst.1]); + for (_, depth, l) in doc.walk() { + println!(" {}{} {:?} op {} fill {} clipped {} visible {} fx {:?}", " ".repeat(depth), l.name, l.blend, l.opacity, l.fill_opacity, l.clipped, l.visible, l.effects.items.iter().map(|e| e.label()).collect::>()); + } + } + } + Err(e) => println!("{p:<70} {fx:>2} fx layers CPU fallback: {e}"), + } + } + println!("worst over all files: {:.2}/255", worst_all * 255.0); } fn main() { let args: Vec = std::env::args().collect(); + if let Some(i) = args.iter().position(|a| a == "--compare") { + compare(&args[i + 1..]); + return; + } let no_cpu = args.iter().any(|a| a == "--no-cpu"); let mut doc = if let Some(p) = arg(&args, "--psd") { let bytes = std::fs::read(&p).expect("read --psd"); @@ -162,38 +197,88 @@ fn main() { Some(g) => gpu_time(g, doc, r), None => Err("no adapter".into()), }; - let cpu = |doc: &Document, r: Rect| if no_cpu { None } else { Some(cpu_time(doc, r)) }; + + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5); + let stats = |v: &mut Vec| -> Option<(f64, f64)> { + v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + (!v.is_empty()).then(|| (v[0], v[v.len() / 2])) + }; + let show = |what: &str, r: Rect, c: Option<(f64, f64)>, g: Result<(f64, f64), String>| { + let mp = r.width() as f64 * r.height() as f64 / 1e6; + let c = c.map_or("-".to_string(), |(m, med)| format!("{m:8.1} / {med:8.1} ms")); + let g = match g { + Ok((m, med)) => format!("{m:7.1} / {med:7.1} ms"), + Err(e) => format!("unsupported ({e})"), + }; + println!("{what:<42} {mp:6.2} MP cpu {c:>22} gpu {g} (min / median)"); + }; + // Each case: `edit` mutates the document, then both compositors refresh `region`. + let mut case = |doc: &mut Document, what: &str, n: usize, edit: &mut dyn FnMut(&mut Document, usize) -> Rect, cpu_too: bool| { + let (mut cs, mut gs, mut gerr) = (Vec::new(), Vec::new(), None); + let mut region = full; + for i in 0..n { + region = edit(doc, i).intersect(&full); + if cpu_too && !no_cpu { + cs.push(cpu_time(doc, region)); + } + match gt(doc, region) { + Ok(v) => gs.push(v), + Err(e) => gerr = Some(e), + } + } + show(what, region, stats(&mut cs), match gerr { + Some(e) => Err(e), + None => Ok(stats(&mut gs).unwrap_or((0.0, 0.0))), + }); + }; if args.iter().any(|a| a == "--baseline") { let mut plain = doc.clone(); for l in &mut plain.layers { l.effects.items.clear(); } - let _ = gt(&plain, full); - report("baseline: no effects, full refresh", full, None, gt(&plain, full)); + case(&mut plain, "baseline: no effects, full refresh", reps, &mut |_, _| full, false); } - photocraft_compose::purge_effect_cache(); - report("full refresh, cold effect caches", full, cpu(&doc, full), gt(&doc, full)); - report("full refresh, warm caches", full, cpu(&doc, full), gt(&doc, full)); - + case(&mut doc, "full refresh, cold effect caches", 1, &mut |_, _| { + photocraft_compose::purge_effect_cache(); + full + }, true); + case(&mut doc, "full refresh, warm caches", reps.min(3), &mut |_, _| full, true); // Adjustment tweak (maps unaffected). - if let Some(LayerContent::Adjustment(Adjustment::HueSaturation { hue, .. })) = doc.layers.last_mut().map(|l| &mut l.content) { - *hue += 5.0; - } - report("adjustment tweak (full refresh)", full, cpu(&doc, full), gt(&doc, full)); - - // Brush dab on a plain layer. + case(&mut doc, "adjustment tweak (full refresh)", reps.min(3), &mut |d, _| { + if let Some(LayerContent::Adjustment(Adjustment::HueSaturation { hue, .. })) = d.layers.last_mut().map(|l| &mut l.content) { + *hue += 5.0; + } + full + }, true); + // Brush dabs on a plain layer and on the effect layer: the canvas refreshes the damage rect + // grown by the effect reach (`canvas::effect_reach`). let margin = doc.walk().iter().map(|(_, _, l)| photocraft_compose::effects::margin(l)).max().unwrap_or(0); - let dab = Rect::new(full.width() as i32 / 3, full.height() as i32 / 3, full.width() as i32 / 3 + 64, full.height() as i32 / 3 + 64); - if let Some(l) = doc.layers.iter_mut().find(|l| l.name == "paint") { - l.surface_mut().unwrap().fill_rect(dab, &[0.1, 0.9, 0.1, 1.0]); - report("dab on a plain layer (damage rect)", dab, cpu(&doc, dab), gt(&doc, dab)); - } - // Brush dab on the effect layer: its effects reach `margin` px beyond the dab. - let blob_dab = Rect::new(full.width() as i32 / 2 - 32, full.height() as i32 / 2 - 32, full.width() as i32 / 2 + 32, full.height() as i32 / 2 + 32); - if let Some(l) = doc.layers.iter_mut().find(|l| l.name == "blob") { - l.surface_mut().unwrap().fill_rect(blob_dab, &[0.9, 0.1, 0.1, 1.0]); - let r = blob_dab.inflate(margin).intersect(&full); - report("dab on the effect layer (damage + reach)", r, cpu(&doc, r), gt(&doc, r)); - } + let (w, h) = (full.width() as i32, full.height() as i32); + case(&mut doc, "dab on a plain layer (damage + reach)", reps, &mut |d, i| { + let r = Rect::new(w / 3 + i as i32 * 70, h / 3, w / 3 + i as i32 * 70 + 64, h / 3 + 64); + if let Some(l) = d.layers.iter_mut().find(|l| l.name == "paint") { + l.surface_mut().unwrap().fill_rect(r, &[0.1, 0.9, 0.1, 1.0]); + } + r.inflate(margin) + }, true); + // Moving a text layer by whole pixels: its effect maps move with it. + case(&mut doc, "move a text layer 7 px (old + new bounds)", reps, &mut |d, _| { + let Some(l) = d.layers.iter_mut().find(|l| matches!(l.content, LayerContent::Text(_))) else { return Rect::EMPTY }; + let LayerContent::Text(t) = &mut l.content else { return Rect::EMPTY }; + let Some(src) = t.cache.as_ref() else { return Rect::EMPTY }; + let b = src.content_bounds(); + let to = Rect::new(b.x0 + 7, b.y0, b.x1 + 7, b.y1); + let mut moved = photocraft_raster::Surface::new(src.format()); + moved.write_region(to, &src.read_region(b)); + t.cache = Some(moved); + b.union(&to).inflate(margin) + }, true); + case(&mut doc, "dab on the effect layer (damage + reach)", reps, &mut |d, i| { + let r = Rect::new(w / 2 - 32 + i as i32 * 300, h / 2 - 32, w / 2 + 32 + i as i32 * 300, h / 2 + 32); + if let Some(l) = d.layers.iter_mut().find(|l| l.name == "blob") { + l.surface_mut().unwrap().fill_rect(r, &[0.9, 0.1, 0.1, 1.0]); + } + r.inflate(margin) + }, true); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1183e1e6..deeeff9f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -179,7 +179,7 @@ fn display_doc(app: &PhotocraftApp, idx: usize) -> (std::sync::Arc, u6 pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option<(egui::TextureId, f32)> { let (revision, last_damage, id) = { let st = app.session.documents().get(idx)?; - (st.revision, st.last_damage, st.doc.id) + (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id) }; let (doc, preview_key) = display_doc(app, idx); let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false, tex_revision: 0, tex_preview_key: 0 }); @@ -215,12 +215,30 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) Some((cache.texture.as_ref()?.id(), cache.scale)) } +/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows, +/// strokes, …) on the edited layer and on the groups around it reach that far. +fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 { + layers + .iter() + .filter(|l| l.visible) + .map(|l| { + let own = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 }; + let inner = match &l.content { + LayerContent::Group(g) => effect_reach(&g.children), + _ => 0, + }; + own + inner + }) + .max() + .unwrap_or(0) +} + /// GPU path: make sure document `idx` is current in the GPU canvas. Brush strokes re-composite /// and upload only their damage rect; everything else re-composites the whole document. /// Returns false if there is no GPU canvas. fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool { let Some(gpu) = app.gpu.clone() else { return false }; - let Some((revision, last_damage, id)) = app.session.documents().get(idx).map(|st| (st.revision, st.last_damage, st.doc.id)) else { return false }; + let Some((revision, last_damage, id)) = app.session.documents().get(idx).map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)) else { return false }; let (doc, preview_key) = display_doc(app, idx); let size = [doc.size.width, doc.size.height]; let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false, tex_revision: 0, tex_preview_key: 0 }); @@ -1436,4 +1454,21 @@ mod tests { let p = d.px[0]; assert!((p[0] - 1.0).abs() < 1e-6 && (p[3] - 0.25).abs() < 1e-6, "{p:?}"); } + + #[test] + fn damage_grows_by_nested_effect_reach() { + use photocraft_doc::{Effect, Layer}; + let fmt = photocraft_color::PixelFormat::RGBA8; + assert_eq!(effect_reach(&[Layer::raster("plain", fmt)]), 0); + let mut inner = Layer::raster("inner", fmt); + inner.effects.items.push(Effect::default_drop_shadow()); + let m = photocraft_compose::effects::margin(&inner); + let mut group = Layer::group("g", vec![inner.clone()]); + group.effects.items.push(Effect::default_drop_shadow()); + assert_eq!(effect_reach(std::slice::from_ref(&inner)), m); + // A child's edit moves the group's shape, whose effects reach further. + assert_eq!(effect_reach(&[group.clone()]), 2 * m); + group.visible = false; + assert_eq!(effect_reach(&[group]), 0); + } } diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 1d3e9c66..f6de33b7 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -623,7 +623,7 @@ fn draw_mesh(d: &LiquifyDialog, painter: &egui::Painter, area: ERect) { } let stroke = Stroke::new(0.75, Color32::from_rgba_unmultiplied(90, 90, 90, 210)); let pos = |i: usize, j: usize| [x0 + i as f64 * h, y0 + j as f64 * h]; - for axis in 0..2 { + for axis in [0usize, 1] { let origin = if axis == 0 { x0 } else { y0 }; for jj in 0..ny - 1 { for ii in 0..nx - 1 { diff --git a/docs/development.md b/docs/development.md index daced0c2..8649d9eb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,9 @@ Image code is slow at `opt-level 0`, so the workspace profile builds dependencie | `PHOTOCRAFT_CONTROL_PORT` | Same as `--control ` | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | -| `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the layer-effect map cache (`compose::effect_maps`) | +| `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the CPU layer-effect map cache (`compose::effect_maps`) | +| `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) | +| `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens @@ -164,8 +166,11 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par ## Performance notes - The canvas is presented by a custom WGSL shader (`ui-egui/src/gpu_canvas.rs`): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect. -- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`). Anything the GPU planner can't express (layer effects, layers clipped to pass-through groups, documents over the texture limit) returns `Unsupported` and falls back to the CPU compositor (`photocraft-compose`), which is also the reference for export and tests. -- **Layer effects** are expensive. `compose::effect_maps` caches shadow, glow, bevel and satin maps per layer state (LRU, 768 MB budget). +- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): vector masks, layers clipped to pass-through groups, stroked shapes with clipped layers, pattern fills without Photoshop's cached pixels, artboards, and documents or effect regions over the texture limit. `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path). +- **Layer effects on the GPU** (`gpu/src/fx.rs`, kernels in `gpu/src/compose.wgsl`). Every enabled effect becomes a *map program* over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroring `compose::effects` step by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (`compose::layer_shape`) and its distance fields (`compose::effects::distance_field`: a sequential transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU, computed in parallel bands. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budget `gpu::FX_BUDGET` (1.5 GB, least recently drawn layers evicted first). `PHOTOCRAFT_FX_TRACE=1` prints the CPU time of each rebuild. +- The canvas grows a stroke's damage rect by the effect reach of the layers around it (`canvas::effect_reach`), so effects beyond the dab refresh too (on both paths). +- **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`): FX_NUMBERS. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files. +- On the CPU path `compose::effect_maps` caches shadow, glow, bevel and satin maps per layer state (LRU, 768 MB budget). - Live adjustment previews on large documents use a downsampled proxy (`ui-egui/src/proxy.rs`). - `ui.inspect` returns `perf` timings (UI ms per frame, composite ms, upload ms). - Never scan full surfaces per frame. Cache per document revision (`PhotocraftApp::cached_bounds`). An uncached `content_bounds()` on a 36 MP layer once cost 77 ms per frame. @@ -220,8 +225,12 @@ corpus PSD with Photoshop's own merged image (PASS ≤ 2/255). To dig into one f cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/.psd 0 png /tmp/diff.png ``` -writes ours | Photoshop | a diff heatmap side by side; `col`, `row` and `DUMP_FX=1` print samples and raw +writes ours | Photoshop | a diff heatmap side by side; `col`, `row`, `worst [n]`, `grid x0 y0 x1 y1 [ch]`, +`layerpx x y` and `DUMP_FX=1` print samples, the worst pixels, value grids, per-layer pixels and raw effect descriptors. Findings so far: fill-layer gradients are framed by the layer's mask bounds; Photoshop's gradient Smoothness is a Catmull-Rom blend and "Perceptual" interpolation is Oklab (baked into dense stops on import, `crates/io/src/gradient_bake.rs`); a shape layer's vector -stroke is drawn above its clipped layers. +stroke is drawn above its clipped layers; linked effect patterns tile from the layer's `fxrp` +reference point; stroke distances follow a 5 × 5 chamfer metric (1, √2, √5) seeded at sub-pixel +edge offsets; interior effects keep the layer's alpha; outside strokes blend onto the backdrop with +their own modes, an upper stroke covering lower ones. diff --git a/docs/roadmap.md b/docs/roadmap.md index c701aa72..54756799 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,11 +13,11 @@ composite oracle and the test count. | M2 PSD v1 | ✅ | photocraft-psd: 134/135 real files byte-exact round trip | | M3 Viewer app | ✅ | egui shell (Pro / Studio / Classic themes), 13+ codecs, native and web (trunk) builds | | M4 Native format + engine | ✅ | 500+ commands, `.pcraft` (incremental, autosave, crash recovery), CLI, persistent preferences | -| M5 GPU compositor | 🟡 | wgpu compositor drives the canvas; layer effects, pattern fills and a few clip cases fall back to the CPU | +| M5 GPU compositor | 🟡 | wgpu compositor drives the canvas, layer effects included (≤1/255 vs CPU); vector masks, uncached pattern fills, artboards and a few clip cases fall back to the CPU | | M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; native tablet backends pending | | M7 Adjust + filters | 🟡 | 16 adjustment layers + destructive-only adjustments, 70+ filters incl. Blur Gallery, Actions record/replay, Fade | -| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle 102/170 | -| M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU; GPU effects pending | +| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle 111/170 | +| M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU and GPU (parity ≤1/255, 30/31 corpus effect files on the GPU) | | M10 Smart features | 🟡 | classical Select Subject / Object, content-aware fill and scale, healing, auto-align / auto-blend; ML backend not started | | M11 Automation + formats | 🟡 | MCP (headless + live bridge), batch, Image Processor, prefs over MCP; DoD test passes (10 agent tasks over MCP, `automation/tests/agent_tasks.rs`); JP2 / DICOM / DPX / C2PA pending | | M12 Pro parity | 🟡 | CMYK / Lab / Indexed / Bitmap / Duotone, ICC + soft proofing, channels + Quick Mask, smart-object stack modes; print, HDR, photomerge, timeline, artboards, layer comps pending | @@ -30,8 +30,9 @@ Landed on 2026-10-01: multi-layer selection, live smart objects + smart filters, Quick Mask, patterns, Warp, preferences, snapping, ~33 filters, the remaining core adjustments. Next: -1. **Fidelity**: the PSD oracle (102/170). Linked-pattern phase origin, layer effects (bevel, - satin, multi-stroke), grayscale adjustments; PSD link groups; Photoshop smart filters in `SoLd`. +1. **Fidelity**: the PSD oracle (111/170). Bevel/emboss, satin, inner glow; modern + Brightness/Contrast and grayscale Levels curves; channel restrictions (`brst`); Lab-space + blending; Photoshop smart filters in `SoLd`. 2. **Layer Comps and Artboards** (File/Layer/View items and export depend on them). 3. **GPU layer effects** (the last big CPU fallback on the canvas). 4. **Puppet Warp, Perspective Warp, Liquify, Vanishing Point, Camera Raw / Lens Correction**.