work in progress
This commit is contained in:
parent
6b44c393fa
commit
aad724f28e
|
|
@ -3418,6 +3418,7 @@ dependencies = [
|
|||
"photocraft-doc",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"photocraft-vector",
|
||||
"pollster 1.0.1",
|
||||
"rayon",
|
||||
"wgpu",
|
||||
|
|
|
|||
|
|
@ -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<f32>, Vec<usize>) {
|
|||
/// 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<f32>, Vec<usize>) {
|
||||
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<f32>, w: usize, h: usize) -> (Vec<f32>, Vec<usize>) {
|
|||
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<usize> = (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<f32>, near: &mut Vec<usize>| {
|
||||
let relax = |x: i64, y: i64, sign: i64, d: &mut Vec<f32>, near: &mut Vec<usize>| {
|
||||
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<f32>, Vec<usize>) {
|
||||
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<f32> {
|
||||
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<f32> {
|
|||
return d.iter().zip(&s.v).map(|(d, &a)| if a > INSIDE_EPS { -0.5 } else { d - 0.5 }).collect();
|
||||
}
|
||||
let inside: Vec<bool> = 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<f32> {
|
|||
return d.iter().zip(&s.v).map(|(d, &a)| if a <= INSIDE_EPS { -0.5 } else { d - 0.5 }).collect();
|
||||
}
|
||||
let outside: Vec<bool> = 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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<uniform> chunk: Chunk;
|
||||
@group(0) @binding(1) var<uniform> op: Op;
|
||||
|
|
@ -675,9 +675,10 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
|
|||
}
|
||||
|
||||
// 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<f32> {
|
||||
let p = local(in.pos);
|
||||
|
|
@ -687,7 +688,7 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4<f32> {
|
|||
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<f32> {
|
|||
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<f32> {
|
||||
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<f32> {
|
||||
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<f32>, b: vec4<f32>, k: f32) -> vec4<f32> {
|
||||
let alpha = a.a + (b.a - a.a) * k;
|
||||
if (alpha <= 0.0) { return vec4(0.0); }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<ResidentRef>, Option<ResidentRef>)>,
|
||||
keys: Vec<(LayerId, Role)>,
|
||||
maps: Vec<Option<(wgpu::TextureView, Rect)>>,
|
||||
patterns: Vec<Option<wgpu::TextureView>>,
|
||||
_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<ResidentRef>, Option<ResidentRef>)> = 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<u32>| 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<std::time::Instant> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static T: std::sync::OnceLock<bool> = 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<Rect>) -> Vec<u8> {
|
||||
let mut flags = p.flags;
|
||||
|
|
|
|||
|
|
@ -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<Slot>,
|
||||
pub b: Option<Slot>,
|
||||
pub c: Option<Slot>,
|
||||
/// A fourth slot, bound in place of `tex` (passes without layer pixels).
|
||||
pub d: Option<Slot>,
|
||||
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<Slot>, Option<Slot>) = (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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,6 +451,14 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
|
|||
("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}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<usize> = 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(" "));
|
||||
}
|
||||
|
|
@ -240,9 +240,10 @@ fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool) -> Vec<u8> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<f64>, gpu: Result<f64, String>) {
|
||||
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::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
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<String> = 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<f64>| -> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ fn display_doc(app: &PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ Image code is slow at `opt-level 0`, so the workspace profile builds dependencie
|
|||
| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control <port>` |
|
||||
| `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/<file>.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.
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
Loading…
Reference in New Issue