Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 bc7144cf28 Move tool: layers overhanging the canvas keep their effect maps; moves copy 2x faster (#761, #128)
- A layer about the canvas's size keeps its whole effect region even where it overhangs the
  edge, so the region keeps its size while the layer is dragged and the GPU moves its effect
  maps with it instead of rebuilding the shape, distance fields and every effect each frame.
- Surface::translated moves a surface by copying encoded tile bytes tile to tile (no float
  round trip, no full-size intermediate; whole-tile moves share the tiles): 2.3x faster than
  to_interleaved + write_interleaved. Used by layer.translate, Move tool drags and
  algo::resample::translate_surface.
- perf_scenarios: Move tool drag frame rows (styled layer, full-canvas layer with and without
  a drop shadow).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 18:21:50 +00:00
6 changed files with 153 additions and 14 deletions

No files matched your search

+1 -6
View File
@@ -287,12 +287,7 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
/// Moves a surface by whole pixels (default pixel kept).
pub fn translate_surface(s: &Surface, dx: i32, dy: i32) -> Surface {
let mut out = Surface::with_default(s.format(), &s.default_pixel());
let r = s.content_bounds();
if !r.is_empty() {
out.write_interleaved(r.translate(dx, dy), &s.to_interleaved(r));
}
out
s.translated(dx, dy, s.content_bounds())
}
/// Keeps only the pixels inside `keep` (others become the default pixel).
+1 -6
View File
@@ -290,12 +290,7 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Docume
/// `photocraft_algo::resample::translate_surface` with the content scan cached per tile (a Move
/// drag shifts the same layers every frame; scanning them each time cost more than the copy).
pub(crate) fn shift_surface(s: &photocraft_raster::Surface, dx: i32, dy: i32) -> photocraft_raster::Surface {
let mut out = photocraft_raster::Surface::with_default(s.format(), &s.default_pixel());
let r = photocraft_compose::bounds::content_bounds(s);
if !r.is_empty() {
out.write_interleaved(r.translate(dx, dy), &s.to_interleaved(r));
}
out
s.translated(dx, dy, photocraft_compose::bounds::content_bounds(s))
}
/// [`crate::commands::translate_layer`] plus the vector side, without re-rendering anything.
+7 -2
View File
@@ -15,10 +15,15 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
}
/// The region a layer's effect maps cover: its bounds grown by the effect reach, within the
/// canvas grown likewise (`photocraft_compose::effect_maps`).
/// canvas grown likewise (`photocraft_compose::effect_maps`) when the layer reaches far beyond
/// it. A layer about the canvas's size keeps its whole region even where it overhangs the edge:
/// then the region keeps its size while the layer moves (a Move tool drag), so its maps move with
/// it instead of being rebuilt every frame (#761). The maps are exact either way.
pub fn effect_region(layer: &Layer, canvas: Rect) -> Rect {
let m = photocraft_compose::effects::margin(layer);
layer_bounds(layer, canvas).inflate(m).intersect(&canvas.inflate(m))
let (whole, window) = (layer_bounds(layer, canvas).inflate(m), canvas.inflate(m));
let area = |r: Rect| u64::from(r.width()) * u64::from(r.height());
if area(whole) <= area(window).saturating_add(area(window) / 4) { whole } else { whole.intersect(&window) }
}
/// `photocraft_compose::transparent_outside`.
+25
View File
@@ -989,6 +989,31 @@ fn layer_effects_update_incrementally() {
fx_check(&mut g, &d, "moved and painted");
}
#[test]
fn layer_effects_move_with_a_layer_overhanging_the_canvas() {
// A layer the size of the canvas with a drop shadow and a stroke, dragged with the Move tool
// (#761): its pixels overhang the edge, yet each move reuses the maps instead of rebuilding
// them, and the result still matches the CPU.
let Some(mut g) = gpu() else { return };
let mut d = fx_doc(160, 120, SampleType::U8);
let mut l = noise_layer("full", PixelFormat::RGBA8, Rect::new(-6, -4, 166, 124), 3, 0.6);
l.surface_mut().unwrap().fill_rect(Rect::new(40, 30, 90, 70), &[0.0, 0.0, 0.0, 0.0]);
l.effects.items = vec![
Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 6.0, 5.0, 0.0)),
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
];
d.layers.push(l);
let first = fx_check(&mut g, &d, "initial");
assert!(first.fx_programs > 0);
for (dx, dy) in [(3, 2), (5, -4), (-9, 7)] {
let s = d.layers[1].surface().unwrap();
let moved = s.translated(dx, dy, s.content_bounds());
*d.layers[1].surface_mut().unwrap() = moved;
let s = fx_check(&mut g, &d, &format!("moved by ({dx}, {dy})"));
assert_eq!(s.fx_programs, 0, "moved by ({dx}, {dy}): {s:?}");
}
}
#[test]
fn layer_effects_on_shape_layers() {
let Some(mut g) = gpu() else { return };
+71
View File
@@ -503,6 +503,46 @@ impl Surface {
out
}
/// The surface moved by whole pixels: its encoded samples copied tile to tile (no float
/// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves.
/// `content` must hold every pixel that isn't the default one (`content_bounds`, say): only
/// tiles it reaches are kept.
pub fn translated(&self, dx: i32, dy: i32, content: Rect) -> Surface {
let mut out = Surface { format: self.format, default_pixel: self.default_pixel.clone(), tiles: BTreeMap::new() };
let sources = || self.tiles.iter().filter(move |(c, _)| !c.rect().intersect(&content).is_empty());
if dx % TILE_SIZE == 0 && dy % TILE_SIZE == 0 {
let (tx, ty) = (dx / TILE_SIZE, dy / TILE_SIZE);
out.tiles = sources().map(|(c, t)| (TileCoord::new(c.tx.saturating_add(tx), c.ty.saturating_add(ty)), t.clone())).collect();
return out;
}
let bpp = self.format.bytes_per_pixel();
let moved = content.translate(dx, dy);
let targets: std::collections::BTreeSet<TileCoord> = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect();
for dc in targets {
let mut tile = Tile::filled(&self.format, &self.default_pixel);
let dr = dc.rect();
let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg());
for sc in sr.intersect(&content).tiles() {
let Some(st) = self.tiles.get(&sc) else { continue };
let part = sc.rect().intersect(&sr);
let span = part.width() as usize * bpp;
// Offsets inside the tiles (the target's computed wide: an offset near the i32
// limits saturates `translate`, and such rows are skipped rather than misplaced).
let at = |x: i64, y: i64| usize::try_from(y * i64::from(TILE_SIZE) + x).ok().map(|i| i * bpp);
for y in part.y0..part.y1 {
let src = at(i64::from(part.x0 - sc.tx * TILE_SIZE), i64::from(y - sc.ty * TILE_SIZE));
let dst = at(i64::from(part.x0) + i64::from(dx) - i64::from(dr.x0), i64::from(y) + i64::from(dy) - i64::from(dr.y0));
let (Some(src), Some(dst)) = (src, dst) else { continue };
if let (Some(d), Some(s)) = (tile.data.get_mut(dst..dst + span), st.data.get(src..src + span)) {
d.copy_from_slice(s);
}
}
}
out.tiles.insert(dc, Arc::new(tile));
}
out
}
/// Convert to another pixel format (depth and/or model; colour models via [`convert_pixel`]).
pub fn convert(&self, to: PixelFormat) -> Surface {
let mut out = Surface::with_default(to, &convert_pixel(&self.format, &to, &self.default_pixel()));
@@ -772,6 +812,37 @@ mod tests {
assert!((v[0] - v[2]).abs() < 1e-6 && (v[3] - 1.0).abs() < 1e-6);
}
#[test]
fn translated_matches_reading_the_moved_pixels() {
// Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default.
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] {
let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]);
let r = Rect::new(-37, 200, 530, 300);
let v: Vec<f32> = (0..r.width() * r.height() * fmt.channels() as u32).map(|i| (i % 251) as f32 / 250.0).collect();
s.write_region(r, &v);
for (dx, dy) in [(0, 0), (256, -512), (1, 0), (-3, 7), (300, -257), (-1000, 999)] {
let t = s.translated(dx, dy, s.content_bounds());
let moved = r.translate(dx, dy).inflate(3);
assert_eq!(t.read_region(moved), s.read_region(moved.translate(-dx, -dy)), "{fmt:?} by ({dx}, {dy})");
assert_eq!(t.default_pixel(), s.default_pixel());
assert_eq!(t.to_interleaved(r.translate(dx, dy)), s.to_interleaved(r), "{fmt:?} by ({dx}, {dy}): not an exact copy");
}
// Whole tiles: the tiles are shared, not copied.
let t = s.translated(512, -256, s.content_bounds());
assert!(s.tiles().zip(t.tiles()).all(|((_, a), (_, b))| Arc::ptr_eq(a, b)));
// Tiles without content (allocated, default pixels) are left behind.
let mut padded = s.clone();
padded.fill_rect(Rect::new(2000, 2000, 2100, 2100), &s.default_pixel());
assert_eq!(padded.translated(5, 5, s.content_bounds()).tile_count(), s.translated(5, 5, s.content_bounds()).tile_count());
}
// Offsets at the limits don't panic.
let mut s = Surface::new(PixelFormat::GRAY8);
s.write_pixel(5, 5, &[1.0]);
let all = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX);
let _ = s.translated(i32::MAX, i32::MIN, all);
let _ = s.translated(i32::MIN + 1, 3, all);
}
proptest! {
#[test]
fn region_write_read_roundtrip(x in -600i32..600, y in -600i32..600, w in 1u32..90, h in 1u32..90, seed in any::<u32>()) {
+48
View File
@@ -127,6 +127,31 @@ impl Bench {
Ok(ms(t))
}
/// Frame `i` of a Move tool drag of `layer` (#128): the canvas shows the document with the
/// layer at the pointer (`layer_multi_cmds::moved`) and recomposites where it was in the
/// previous frame and where it is now (`move_ui::damage`).
fn drag_frame(&self, s: &Session, layer: u64, i: usize) -> Res<f64> {
let doc = s.active().ok_or("no document")?.doc.clone();
let id = photocraft_doc::LayerId(layer);
let all = photocraft_geom::Rect::new(i32::MIN / 2, i32::MIN / 2, i32::MAX / 2, i32::MAX / 2);
let was = doc.layer(id).and_then(|l| photocraft_compose::change_bounds(l, all)).ok_or("no bounds")?;
// Frame i shows offset (d, d / 2); the canvas showed the previous frame's.
let (d, prev) = (3 * i as i32 + 1, if i == 0 { 0 } else { 3 * i as i32 - 2 });
let t = Instant::now();
let shown = photocraft_engine::layer_multi_cmds::moved(&doc, &[id], d, d / 2).map_err(|e| e.to_string())?;
let damage = was.translate(prev, prev / 2).union(&was.translate(d, d / 2)).inflate(1).intersect(&doc.bounds());
match &self.gpu {
Some((g, rs)) => {
g.refresh(doc.id.0, &shown, Some(damage), None);
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
}
None => {
std::hint::black_box(photocraft_compose::render(&shown, damage));
}
}
Ok(ms(t))
}
fn gpu_bytes(&self, s: &Session) -> Option<u64> {
let (g, _) = self.gpu.as_ref()?;
let tex = s.active().and_then(|d| g.texture_info(d.doc.id.0)).map_or(0, |(_, b)| b);
@@ -363,6 +388,7 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
exec(s, "layer.translate", json!({"layer": target, "dx": d, "dy": d / 2}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("move tool drag frame, styled layer", &mut s, reps, true, |b, s, i| b.drag_frame(s, target, i));
b.time("set layer opacity + refresh", &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?;
@@ -400,6 +426,28 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
Ok(ms(t) + b.refresh(s, false)?)
});
// A layer filling the canvas, with a drop shadow (#761) and without (#128): dragged, it
// overhangs the canvas edge.
let full = (|| -> Res<u64> {
exec(&mut s, "layer.new.layer", json!({"name": "full"}))?;
exec(&mut s, "select.all", json!({}))?;
exec(&mut s, "edit.fill", json!({"color": "#4080c0"}))?;
exec(&mut s, "select.deselect", json!({}))?;
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 12, "size": 16}))?;
active_layer(&s)
})();
match full {
Ok(full) => {
let _ = b.refresh(&s, true);
b.time("move tool drag frame, full-canvas layer with drop shadow", &mut s, reps, true, |b, s, i| b.drag_frame(s, full, i));
let _ = exec(&mut s, "layer.layerStyle.clear", json!({"layer": full}));
let _ = b.refresh(&s, true);
b.time("move tool drag frame, full-canvas layer", &mut s, reps, true, |b, s, i| b.drag_frame(s, full, i));
let _ = exec(&mut s, "layer.delete", json!({"layer": full}));
}
Err(e) => b.errors.push(("full-canvas styled layer".into(), e)),
}
// A 12 MP layer, duplicated and pasted: the first time (a fresh layer, cold caches) and
// again (the same source).
let (lw, lh) = sz.big_layer;