Merge pull request #5332 from vistaero/codex/gpu-viewport-streaming

Stream GPU viewport pages with bounded staging and cancellation
This commit is contained in:
FredApps authored and GitHub committed 2026-11-03 13:50:45 +01:00
commit fd32d25270
17 files changed
+1626 -17

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ pub mod proxy;
pub mod psblend;
pub mod shape_split;
mod uniform;
pub mod viewport;
use photocraft_color::blend::BlendMode;
use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern};
+57 -11
View File
@@ -2,6 +2,7 @@ use super::content::RENDER_TILE;
use super::ctx::Ctx;
use super::render::{band_rows_for, render, render_bands, render_tiled_with};
use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform};
use photocraft_raster::Interrupt;
/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is
/// faithful; smaller ones are reduced from the exact composite.
@@ -101,23 +102,66 @@ pub(crate) fn render_reduced_with(
uniform: bool,
stats: &mut ReducedStats,
) -> Buffer {
match render_reduced_cells(doc, b, Cells::Fit(w, h), damage, band_rows, uniform, stats, Interrupt::NONE) {
Ok(buf) => buf,
// Only an interrupt fails a reduction, and this one has none.
Err(_) => Buffer::transparent(Rect::EMPTY),
}
}
/// How a reduced render maps document pixels of its area to output pixels.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Cells {
/// `w`×`h` output pixels spread evenly over the area ([`render_reduced`]).
Fit(u32, u32),
/// Output pixel `t` covers the area's pixels `[t·k, (t+1)·k)`, clipped to the area: a
/// globally aligned reduction by `k` when the area starts on a multiple of `k`
/// ([`crate::viewport::render_page`]).
Factor(u32),
}
/// [`render_reduced_with`] on a cell grid, checking `interrupt` between bands.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_reduced_cells(
doc: &Document,
b: Rect,
cells: Cells,
damage: Option<Rect>,
band_rows: i32,
uniform: bool,
stats: &mut ReducedStats,
interrupt: Interrupt<'_>,
) -> Result<Buffer, String> {
let (fw, fh) = (b.width() as usize, b.height() as usize);
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
let (w, h, k) = match cells {
Cells::Fit(w, h) => (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize, None),
Cells::Factor(k) => {
let k = k.max(1);
(b.width().div_ceil(k).max(1) as usize, b.height().div_ceil(k).max(1) as usize, Some(k as usize))
}
};
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
if fw == 0 || fh == 0 {
return Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full });
return Ok(Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full }));
}
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n).
let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n)
// (or [t·k, (t+1)·k) on a factor grid).
let span = |t: usize, f: usize, n: usize| match k {
Some(k) => ((t * k).min(f), ((t + 1) * k).min(f)),
None => (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)),
};
// The output pixel whose span holds document column / row `x` (spans are contiguous: n <= f).
let cell = |x: usize, f: usize, n: usize| ((x + 1) * n).saturating_sub(1) / f;
let cell = |x: usize, f: usize, n: usize| match k {
Some(k) => x / k,
None => ((x + 1) * n).saturating_sub(1) / f,
};
// Output pixels to produce and the document area they cover.
let out = match damage {
None => full,
Some(d) => {
let d = d.intersect(&b);
if d.is_empty() {
return Buffer::transparent(Rect::EMPTY);
return Ok(Buffer::transparent(Rect::EMPTY));
}
let (x0, x1) = ((d.x0 - b.x0) as usize, (d.x1 - b.x0) as usize);
let (y0, y1) = ((d.y0 - b.y0) as usize, (d.y1 - b.y0) as usize);
@@ -131,7 +175,7 @@ pub(crate) fn render_reduced_with(
if (w, h) == (fw, fh) {
let mut px = render(doc, src);
px.rect = out;
return px;
return Ok(px);
}
let map = |f: usize, n: usize| {
let mut m = vec![0u32; f];
@@ -200,6 +244,7 @@ pub(crate) fn render_reduced_with(
let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize;
let mut y = grid(sy0, b.y0);
while y < sy1 as i64 {
interrupt.check().map_err(|e| e.to_string())?;
let y1 = (y + band_h as i64).min(sy1 as i64);
let tiles: Vec<(Rect, Rect)> = (y..y1)
.step_by(t)
@@ -255,7 +300,8 @@ pub(crate) fn render_reduced_with(
y = y1;
}
} else {
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
render_bands(doc, src, band_rows, |band| -> Result<(), String> {
interrupt.check().map_err(|e| e.to_string())?;
stats.composited += band.px.len() as u64;
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
let sw = sx1 - sx0;
@@ -265,8 +311,7 @@ pub(crate) fn render_reduced_with(
}
});
Ok(())
});
debug_assert!(ok.is_ok());
})?;
}
let col_w: Vec<usize> = (0..ow)
.map(|x| {
@@ -296,5 +341,6 @@ pub(crate) fn render_reduced_with(
for (row, out) in acc.chunks_mut(ow).enumerate() {
norm_row(row, out);
}
Buffer { rect: out, px: acc }
interrupt.check().map_err(|e| e.to_string())?;
Ok(Buffer { rect: out, px: acc })
}
+260
View File
@@ -0,0 +1,260 @@
//! Bounded, cancellable viewport pages. Reduction is globally aligned, so independently
//! rendered pages have exactly the same pixel footprints at their shared edges.
use crate::{Buffer, render, render_bands};
use photocraft_doc::Document;
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
/// A quick, explicitly provisional reduction. Only the requested source rows are read; the
/// exact composite below replaces it after visible pages have first become available.
pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
if factor <= 1 || !crate::proxy::proxy_faithful(doc) {
return render_page(doc, source, factor, interrupt);
}
let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?;
let source = source.intersect(&doc.bounds());
if source.is_empty() {
return Ok(Buffer::transparent(Rect::EMPTY));
}
let rect = Rect::new(
source.x0 / k,
source.y0 / k,
source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0),
source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0),
);
if rect.width() > 1024 || rect.height() > 1024 {
return Err("viewport page is too large".into());
}
fn shrink(s: &Surface, r: Rect, k: i32, i: Interrupt<'_>) -> Result<Surface, String> {
let mut out = Surface::with_default(s.format(), &s.default_pixel());
let bpp = s.format().bytes_per_pixel();
for y in r.y0..r.y1 {
i.check().map_err(|e| e.to_string())?;
let sy = y.saturating_mul(k).saturating_add(k / 2);
let row = s.to_interleaved(Rect::new(r.x0.saturating_mul(k), sy, r.x1.saturating_mul(k), sy.saturating_add(1)));
let mut reduced = Vec::with_capacity(r.width() as usize * bpp);
for x in 0..r.width() as usize {
let offset = (x * k as usize + k as usize / 2) * bpp;
reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?);
}
out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced);
}
Ok(out)
}
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize) -> Result<(), String> {
if depth > 256 {
return Err("viewport layer nesting limit exceeded".into());
}
if let Some(m) = &mut l.mask {
m.surface = shrink(&m.surface, r, k, i)?;
}
if let Some(c) = &mut l.fill_cache {
c.surface = shrink(&c.surface, r, k, i)?;
}
use photocraft_doc::LayerContent;
match &mut l.content {
LayerContent::Raster(s) => *s = shrink(s, r, k, i)?,
LayerContent::Group(g) => {
if let Some(a) = &mut g.artboard {
a.rect = Rect::new(a.rect.x0.div_euclid(k), a.rect.y0.div_euclid(k), a.rect.x1.div_euclid(k), a.rect.y1.div_euclid(k));
}
for c in &mut g.children {
layer(c, r, k, i, depth + 1)?;
}
}
LayerContent::Text(t) => {
if let Some(s) = &mut t.cache {
*s = shrink(s, r, k, i)?;
}
}
LayerContent::Shape(t) => {
if let Some(s) = &mut t.cache {
*s = shrink(s, r, k, i)?;
}
}
LayerContent::Smart(t) => {
if let Some(s) = &mut t.cache {
*s = shrink(s, r, k, i)?;
}
}
_ => {}
}
Ok(())
}
let mut p = doc.clone();
p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor));
for l in &mut p.layers {
layer(l, rect, k, interrupt, 0)?;
}
for c in &mut p.channels {
c.surface = shrink(&c.surface, rect, k, interrupt)?;
}
interrupt.check().map_err(|e| e.to_string())?;
Ok(render(&p, rect))
}
pub fn render_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
interrupt.check().map_err(|e| e.to_string())?;
let source = source.intersect(&doc.bounds());
if source.is_empty() {
return Ok(Buffer::transparent(Rect::EMPTY));
}
let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?;
let out = Rect::new(
source.x0.div_euclid(k),
source.y0.div_euclid(k),
source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0),
source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0),
);
if out.width() > 1024 || out.height() > 1024 {
return Err("viewport page is too large".into());
}
if k == 1 {
return Ok(render(doc, source));
}
if source.x0.rem_euclid(k) != 0 || source.y0.rem_euclid(k) != 0 {
return average_page(doc, source, k, out, interrupt);
}
// The reduced-render path the CPU canvas and navigator use: one-colour document tiles are
// composited once instead of per pixel, and the rest in parallel tiles, then area-averaged
// into cells aligned to multiples of `k` (the same cells as [`average_page`]).
let stats = &mut crate::reduced::ReducedStats::default();
let mut buf = crate::reduced::render_reduced_cells(doc, source, crate::reduced::Cells::Factor(factor), None, 128, true, stats, interrupt)?;
if buf.rect.width() != out.width() || buf.rect.height() != out.height() {
return Err("invalid viewport reduction".into());
}
buf.rect = out;
Ok(buf)
}
/// [`render_page`] by compositing every source pixel and averaging each `k`×`k` footprint (also
/// the reference its reduced path is tested against).
fn average_page(doc: &Document, source: Rect, k: i32, out: Rect, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
let factor = k.unsigned_abs();
let mut result = Buffer::transparent(out);
render_bands(doc, source, 128, |band| -> Result<(), String> {
interrupt.check().map_err(|e| e.to_string())?;
for (i, p) in band.px.iter().enumerate() {
let x = band.rect.x0 + (i % band.rect.width() as usize) as i32;
let y = band.rect.y0 + (i / band.rect.width() as usize) as i32;
let offset = (y.div_euclid(k) - out.y0) as usize * out.width() as usize + (x.div_euclid(k) - out.x0) as usize;
let q = result.px.get_mut(offset).ok_or("invalid viewport accumulator")?;
for (a, b) in q.iter_mut().take(3).zip(p.iter()) {
*a += *b * p.get(3).copied().unwrap_or(0.0);
}
if let Some(a) = q.get_mut(3) {
*a += p.get(3).copied().unwrap_or(0.0);
}
}
Ok(())
})?;
for (i, p) in result.px.iter_mut().enumerate() {
let x = out.x0 + (i % out.width() as usize) as i32;
let y = out.y0 + (i / out.width() as usize) as i32;
let footprint = Rect::from_xywh(x.saturating_mul(k), y.saturating_mul(k), factor, factor).intersect(&source);
let alpha = p.get(3).copied().unwrap_or(0.0);
if alpha > 0.0 {
for c in p.iter_mut().take(3) {
*c /= alpha;
}
if let Some(a) = p.get_mut(3) {
*a /= (u64::from(footprint.width()) * u64::from(footprint.height())).max(1) as f32;
}
}
}
interrupt.check().map_err(|e| e.to_string())?;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::Size;
#[test]
fn high_zoom_is_original_resolution_at_every_depth() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let r = Rect::new(65001, 22003, 65513, 22515);
let mut d = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, depth);
let mut s = photocraft_raster::Surface::new(d.pixel_format());
s.fill_rect(r, &[1.0; 4]);
d.layers.push(photocraft_doc::Layer::new("region", photocraft_doc::LayerContent::Raster(s)));
let p = render_page(&d, r, 1, Interrupt::NONE).unwrap();
assert_eq!(p.rect, r);
assert_eq!(p.px, render(&d, r).px);
}
}
#[test]
fn independent_pages_match_whole_reduction_and_odd_edges() {
let d = Document::with_background("d", Size::new(53, 31), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let whole = render_page(&d, d.bounds(), 4, Interrupt::NONE).unwrap();
for r in [Rect::new(0, 0, 28, 31), Rect::new(28, 0, 53, 31)] {
let p = render_page(&d, r, 4, Interrupt::NONE).unwrap();
for y in p.rect.y0..p.rect.y1 {
for x in p.rect.x0..p.rect.x1 {
assert_eq!(
p.px[((y - p.rect.y0) * p.rect.width() as i32 + x - p.rect.x0) as usize],
whole.px[(y * whole.rect.width() as i32 + x) as usize]
);
}
}
}
}
/// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour
/// tiles, so the reduced path takes both its one-colour and per-pixel branches.
fn busy(depth: SampleType) -> Document {
let mut d = Document::with_background("busy", Size::new(1100, 700), ColorMode::Rgb, depth, Color::WHITE);
let mut s = photocraft_raster::Surface::new(d.pixel_format());
let mut seed = 0x2545_f491_u32;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed % 1000) as f32 / 999.0
};
for y in (100..530).step_by(3) {
for x in (190..905).step_by(3) {
let px = [next(), next(), next(), next()];
s.fill_rect(Rect::from_xywh(x, y, 3, 3), &px);
}
}
d.layers.push(photocraft_doc::Layer::new("noise", photocraft_doc::LayerContent::Raster(s)));
let mut s = photocraft_raster::Surface::new(d.pixel_format());
s.fill_rect(Rect::new(400, 0, 1100, 650), &[0.2, 0.5, 0.9, 1.0]);
let mut top = photocraft_doc::Layer::new("tint", photocraft_doc::LayerContent::Raster(s));
top.opacity = 0.6;
top.blend = photocraft_color::blend::BlendMode::Multiply;
d.layers.push(top);
d
}
#[test]
fn reduced_pages_match_averaging_every_composited_pixel() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let d = busy(depth);
for k in [2u32, 4, 16] {
let ki = k as i32;
for source in [d.bounds(), Rect::new(512, 256, 1024, 700), Rect::new(ki * 13, ki * 5, 1100, ki * 37)] {
let source = source.intersect(&d.bounds());
let out = Rect::new(source.x0 / ki, source.y0 / ki, (source.x1 + ki - 1) / ki, (source.y1 + ki - 1) / ki);
let reference = average_page(&d, source, ki, out, Interrupt::NONE).unwrap();
let got = render_page(&d, source, k, Interrupt::NONE).unwrap();
assert_eq!(got.rect, reference.rect, "{depth:?} k={k} {source:?}");
let worst = got.px.iter().zip(&reference.px).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
assert!(worst < 1e-4, "{depth:?} k={k} {source:?}: differs by {worst}");
}
}
}
}
#[test]
fn rejects_invalid_sizes_and_honors_cancellation() {
let d = Document::new("d", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8);
assert!(render_page(&d, d.bounds(), 1, Interrupt::NONE).is_err());
assert!(render_page(&d, d.bounds(), 0, Interrupt::NONE).is_err());
assert!(render_page(&d, Rect::new(0, 0, 512, 512), 1, Interrupt::cancel_only(&|| true)).is_err());
// Cancelled after the first band of the reduced path.
let checks = std::sync::atomic::AtomicUsize::new(0);
let later = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 2;
assert!(render_page(&d, Rect::new(0, 0, 4096, 4096), 8, Interrupt::cancel_only(&later)).is_err());
assert!(checks.load(std::sync::atomic::Ordering::Relaxed) >= 3);
}
}
+1
View File
@@ -43,6 +43,7 @@ impl PhotocraftApp {
display_memo: HashMap::new(),
navigator_gpu: HashMap::new(),
navigator_plain: std::collections::HashSet::new(),
streams: HashMap::new(),
info_samplers: None,
monitors: Default::default(),
checker: None,
+2
View File
@@ -346,6 +346,8 @@ impl PhotocraftApp {
}
self.canvases.retain(|(doc, _), _| ids.contains(doc));
self.navigator_textures.retain(|doc, _| ids.contains(doc));
// Closing a document cancels its page work; its GPU pages go with `retain_gpu_documents`.
self.streams.retain(|doc, _| ids.contains(doc));
self.view_docs = ids;
self.prune_thumbs();
self.sync_mask_targets();
+24 -1
View File
@@ -53,6 +53,7 @@ pub mod tool_feedback;
mod tool_input;
mod transform_controls;
mod view_xform;
pub(crate) mod viewport_pages;
pub mod wheel_nav;
pub mod zoom_field;
pub mod zoom_hold;
@@ -65,7 +66,6 @@ pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_c
use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair};
#[cfg(test)]
pub(crate) use display::GPU_OUTPUT;
#[cfg(test)]
use display::texture_key;
pub(crate) use display::{cache_key, canvas_display, display_doc, display_image};
use display::{checker, hdr_preview, sync_display_lut};
@@ -418,6 +418,29 @@ fn paint_canvas(
offset: [0.0, 0.0],
};
paint_gpu_view(painter, rect, params, &pattern);
} else if gpu_ok && viewport_pages::show(app, ctx, idx, &xf, view.zoom) {
// Too large for the document's texture set: the pages in view, rendered in the background.
on_gpu = true;
app.perf.gpu = true;
let key = viewport_pages::stream_key(doc.id.0);
let params = crate::gpu_canvas::ViewParams {
doc: key,
doc_size: [doc.size.width, doc.size.height],
zoom: point_zoom,
center: xf.center,
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
shadow: {
let t = crate::theme::Tokens::get(ctx);
!t.bevel && !t.pro && drop_shadow
},
pixel_grid,
view_key: egui::Id::unique(("pc-canvas-pages", ctx.viewport_id(), doc.id.0)).value(),
display: sync_display_lut(app, doc, key, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, doc),
offset: [0.0, 0.0],
};
paint_gpu_view(painter, rect, params, &pattern);
} else if gpu_ok && ensure_gpu(app, ctx, idx, redraw_focus(&target_xf, &xf)) {
on_gpu = true;
app.perf.gpu = true;
@@ -612,6 +612,90 @@ fn a_canvas_over_the_texture_budget_records_its_reason() {
assert_eq!(g.harness.state().render_path.history().filter(|c| c.doc == doc).count(), 1, "once, not every frame");
}
/// Frames until the active document's viewport pages are all rendered and uploaded.
fn settle_pages(g: &mut Gpu) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
g.harness.step();
let app = g.harness.state();
let id = app.session.active().expect("document").doc.id;
if app.streams.get(&id).is_some_and(|s| !s.busy() && s.stats.wanted > 0 && s.stats.ready == s.stats.wanted) {
return;
}
assert!(std::time::Instant::now() < deadline, "the viewport pages didn't finish");
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
/// The screen pixel at the window's centre.
fn centre_pixel(g: &mut Gpu) -> [u8; 4] {
let img = g.harness.render().expect("render");
let p = img.get_pixel(img.width() / 2, img.height() / 2);
p.0
}
/// #5332: a document whose texture set doesn't fit the canvas budget stays on the GPU canvas,
/// drawn from the pages in view instead of the CPU canvas, with the same pixels; an edit
/// re-renders only the pages, and closing it frees them.
#[test]
fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new");
g.harness.run_steps(3);
assert!(on_gpu_canvas(&g));
let whole = centre_pixel(&mut g);
let gpu = g.harness.state().gpu.clone().expect("gpu canvas");
let doc = g.harness.state().session.active().expect("document").doc.clone();
let key = crate::canvas::viewport_pages::stream_key(doc.id.0);
// Room for pages (half the budget), not for the document's texture set.
let (set, _) = gpu.canvas_fit(&doc).expect("fit");
gpu.set_canvas_budget(set - 1);
settle_pages(&mut g);
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
assert!(gpu.texture_info(doc.id.0).is_none(), "no texture set");
let (pages, bytes) = gpu.pages(key);
let stats = g.harness.state().streams[&doc.id].stats.clone();
assert_eq!(pages.len(), stats.resident);
assert!(stats.wanted > 0 && stats.last_error.is_none(), "{stats:?}");
assert!(bytes <= (set - 1) / 2, "pages stay within half the budget: {bytes}");
let shown = centre_pixel(&mut g);
assert!((0..3).all(|i| shown[i].abs_diff(whole[i]) <= 2), "pages {shown:?}, texture set {whole:?}");
// An edit re-renders the pages it reaches.
let uploads = stats.uploads;
g.harness.state_mut().run("edit.fill", json!({"color": "#cc8844"})).expect("fill");
settle_pages(&mut g);
let stats = g.harness.state().streams[&doc.id].stats.clone();
assert!(stats.dirtied > 0 && stats.uploads > uploads, "{stats:?}");
let filled = centre_pixel(&mut g);
assert!(filled[0] > 0xb0 && filled[2] < 0x60, "the fill is shown: {filled:?}");
// A small edit re-renders only the part of each page it reaches, into the page held.
let (held, partial) = (gpu.pages(key).0.len(), stats.partial);
// (A frame between the steps, as in use: the edit's damage is known one revision at a time.)
g.harness.state_mut().run("select.rect", json!({"x": 1400, "y": 900, "width": 200, "height": 200})).expect("select");
settle_pages(&mut g);
g.harness.state_mut().run("edit.fill", json!({"color": "#22aa22"})).expect("small fill");
settle_pages(&mut g);
let stats = g.harness.state().streams[&doc.id].stats.clone();
assert!(stats.partial > partial, "{stats:?}");
assert_eq!(gpu.pages(key).0.len(), held, "no new page textures");
// Just its square is shown: 200 px at about 20% zoom, some 40 screen pixels a side.
let img = g.harness.render().expect("render");
let green = img.pixels().filter(|p| p.0[1] > 0x90 && p.0[0] < 0x50 && p.0[2] < 0x50).count();
assert!((1000..2500).contains(&green), "the small fill is shown: {green} pixels");
// A budget too small for one page draws it on the CPU canvas, as before.
gpu.set_canvas_budget(1);
g.harness.run_steps(3);
assert_eq!(render_path_of(&g), (Some("canvasTooLarge"), Some("CPU Rendering")));
gpu.set_canvas_budget(set - 1);
settle_pages(&mut g);
// Closing the document cancels its work and frees its pages.
g.harness.state_mut().run("file.close", json!({"save": false})).expect("close");
g.harness.run_steps(3);
assert!(g.harness.state().streams.is_empty());
assert!(gpu.pages(key).0.is_empty());
}
#[test]
fn a_flipped_view_records_why_it_draws_on_the_cpu() {
let _gpu = photocraft_testkit::gpu_lock();
+3 -1
View File
@@ -34,11 +34,13 @@ use eframe::wgpu;
use crate::render_path::CpuReason;
pub use pages::{MAX_PAGE_SIDE, PageUpload};
pub use photocraft_gpu::{DeviceHealth, Fault};
mod budget;
mod callback;
mod doc_textures;
mod pages;
mod perf;
mod resources;
mod setup;
@@ -93,7 +95,7 @@ const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub const F16_BUDGET_PX: u64 = 100_000_000;
const VIEW_UNIFORM_SIZE: u64 = 128;
const VIEW_FLOATS: usize = 32;
const TILE_UNIFORM_SIZE: u64 = 16;
const TILE_UNIFORM_SIZE: u64 = 32;
/// Whether this view can use the GPU canvas. Flip and non-square pixel aspect correction are
/// still CPU blits (the shader's filtering assumes square pixels); rotation is a shader uniform.
@@ -35,6 +35,15 @@ pub(super) fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], u
buffer
}
/// A tile's uniform: its document rect (x, y, w, h) and the exclusive document bounds it draws
/// (x0, y0, x1, y1). A whole-document tile draws all its quad covers (no bound, so rounding at a
/// shared edge cannot open a seam); a viewport page only its core, so the one-texel apron around
/// it filters its edge without being drawn twice.
pub(super) fn tile_uniform([x, y, w, h]: [u32; 4], core: Option<[i32; 4]>) -> [f32; 8] {
let [cx0, cy0, cx1, cy1] = core.map_or([-1e30, -1e30, 1e30, 1e30], |c| c.map(|v| v as f32));
[x as f32, y as f32, w as f32, h as f32, cx0, cy0, cx1, cy1]
}
impl DocTextures {
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
/// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB
@@ -87,7 +96,7 @@ impl DocTextures {
.map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() }))
.collect();
let full = texture.create_view(&wgpu::TextureViewDescriptor::default());
let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), wgpu::BufferUsages::UNIFORM);
let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&tile_uniform([tx, ty, w, h], None)), wgpu::BufferUsages::UNIFORM);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pc_canvas_tile"),
layout: &res.tile_bgl,
@@ -96,7 +105,7 @@ impl DocTextures {
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
],
});
tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform });
tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform, page: None });
}
}
Self { size, format, tiles }
@@ -0,0 +1,183 @@
//! Viewport pages of a document too large for its whole-document texture set (see
//! `canvas::viewport_pages`): independent single-level textures at their own reduction, drawn
//! by the same tile pipeline. They live under their own key, never next to a texture set.
use super::doc_textures::{init_buffer, tile_uniform};
use super::resources::{DocTextures, Resources, Tile, f32_bytes};
use super::texels::{texel_bytes, texels};
use super::{GpuCanvas, Ordering, wgpu};
use photocraft_geom::Rect as DRect;
/// The largest page texture side [`GpuCanvas::upload_page`] accepts (a 512 px page and its apron).
pub const MAX_PAGE_SIDE: u32 = 1024;
/// One page to store: `buf` (document pixels reduced by `factor`, its rect in reduced pixels)
/// drawn over document area `core` of the `size` document `key`.
pub struct PageUpload<'a> {
pub key: u64,
pub page: u64,
pub size: [u32; 2],
pub depth: photocraft_doc::SampleType,
pub factor: u32,
pub core: DRect,
pub buf: &'a photocraft_compose::Buffer,
}
impl DocTextures {
/// GPU bytes held by this set's textures (pages have one level, texture sets their mips).
pub(super) fn page_bytes(&self) -> u64 {
let bpp = texel_bytes(self.format);
self.tiles.iter().filter(|t| t.page.is_some()).map(|t| u64::from(t.texture.width()) * u64::from(t.texture.height()) * bpp).sum()
}
}
impl GpuCanvas {
/// The GPU bytes one page of `w`×`h` texels takes for a `depth` document of `size`.
pub fn page_texture_bytes(&self, depth: photocraft_doc::SampleType, size: [u32; 2], w: u32, h: u32) -> u64 {
u64::from(w) * u64::from(h) * texel_bytes(self.format_for(depth, size))
}
/// Store (or replace) page `page` of `key`. False, with nothing changed, when the device is
/// not healthy, the page is malformed, or the pages of `key` would exceed the canvas budget
/// (the caller evicts its cold pages first; this is the backstop).
/// Writes `buf` (texels at the page's reduction, a part of it) into the page `page` of `key`
/// already resident: an edit's re-render, without a new texture. False when that page isn't
/// held or `buf` falls outside it.
pub fn update_page(&self, key: u64, page: u64, buf: &photocraft_compose::Buffer) -> bool {
let (w, h) = (buf.rect.width(), buf.rect.height());
if !self.health.is_ok() || w == 0 || h == 0 || buf.px.len() as u64 != u64::from(w) * u64::from(h) {
return false;
}
let renderer = self.rs.renderer.read();
let Some(res) = renderer.callback_resources.get::<Resources>() else { return false };
let Some(tile) = res.docs.get(&key).and_then(|d| d.tiles.iter().find(|t| t.page.is_some_and(|(id, _)| id == page))) else {
return false;
};
let Some((_, factor)) = tile.page.filter(|(_, f)| *f > 0) else { return false };
// The texture's first texel, at the page's reduction.
let (ox, oy) = (i64::from(tile.rect[0] / factor), i64::from(tile.rect[1] / factor));
let (x, y) = (i64::from(buf.rect.x0) - ox, i64::from(buf.rect.y0) - oy);
let texture = &tile.texture;
if x < 0 || y < 0 || x + i64::from(w) > i64::from(texture.width()) || y + i64::from(h) > i64::from(texture.height()) {
return false;
}
let format = texture.format();
self.rs.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x: x as u32, y: y as u32, z: 0 }, aspect: wgpu::TextureAspect::All },
&texels(format, &buf.px),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
true
}
pub fn upload_page(&self, p: PageUpload<'_>) -> bool {
let PageUpload { key, page, size, depth, factor, core, buf } = p;
let (w, h) = (buf.rect.width(), buf.rect.height());
if !self.health.is_ok()
|| factor == 0
|| w == 0
|| h == 0
|| w > MAX_PAGE_SIDE
|| h > MAX_PAGE_SIDE
|| buf.rect.x0 < 0
|| buf.rect.y0 < 0
|| buf.px.len() as u64 != u64::from(w) * u64::from(h)
|| core.is_empty()
{
return false;
}
let (Some(x), Some(y), Some(dw), Some(dh)) =
((buf.rect.x0 as u32).checked_mul(factor), (buf.rect.y0 as u32).checked_mul(factor), w.checked_mul(factor), h.checked_mul(factor))
else {
return false;
};
let format = self.format_for(depth, size);
let bytes = u64::from(w) * u64::from(h) * texel_bytes(format);
let (device, queue) = (&self.rs.device, &self.rs.queue);
let mut renderer = self.rs.renderer.write();
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return false };
let budget = res.canvas_budget.load(Ordering::Relaxed);
if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format || d.tiles.iter().any(|t| t.page.is_none())) {
res.docs.insert(key, DocTextures { size, format, tiles: Vec::new() });
}
let Some(d) = res.docs.get_mut(&key) else { return false };
let replaced = d.tiles.iter().position(|t| t.page.is_some_and(|(id, _)| id == page));
let held =
d.page_bytes() - replaced.map_or(0, |i| u64::from(d.tiles[i].texture.width()) * u64::from(d.tiles[i].texture.height()) * texel_bytes(format));
if held.saturating_add(bytes) > budget {
return false;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("pc_viewport_page"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let rect = [x, y, dw, dh];
let uniform =
init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pc_viewport_page"),
layout: &res.tile_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
],
});
queue.write_texture(
texture.as_image_copy(),
&texels(format, &buf.px),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let Some(d) = res.docs.get_mut(&key) else { return false };
if let Some(i) = replaced {
d.tiles.remove(i);
}
let tile = Tile { rect, texture, levels: vec![view], bind_group, _uniform: uniform, page: Some((page, factor)) };
// Coarser pages first: where pages of two zooms overlap, the finer one is drawn on top.
let at = d.tiles.iter().position(|t| t.page.is_some_and(|(_, f)| f < factor)).unwrap_or(d.tiles.len());
d.tiles.insert(at, tile);
true
}
/// Drop the pages of `key` not in `live` (all of them, and the key, when `live` is empty).
pub fn retain_pages(&self, key: u64, live: &[u64]) {
let mut renderer = self.rs.renderer.write();
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
if live.is_empty() {
res.docs.remove(&key);
} else if let Some(d) = res.docs.get_mut(&key) {
d.tiles.retain(|t| t.page.is_some_and(|(id, _)| live.contains(&id)));
}
}
/// Free document `key`'s texture set and what the compositor holds for it, once it is drawn
/// from pages instead: memory the pages are budgeted against.
pub fn release_texture_set(&self, key: u64) {
let mut renderer = self.rs.renderer.write();
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
res.docs.remove(&key);
if let Some(c) = res.compositor.as_mut() {
c.forget_doc(photocraft_doc::DocId(key));
}
}
/// The pages of `key` the GPU holds (page ids), and the bytes they take.
pub fn pages(&self, key: u64) -> (Vec<u64>, u64) {
let r = self.rs.renderer.read();
let Some(d) = r.callback_resources.get::<Resources>().and_then(|res| res.docs.get(&key)) else { return (Vec::new(), 0) };
(d.tiles.iter().filter_map(|t| t.page.map(|(id, _)| id)).collect(), d.page_bytes())
}
/// The per-document display texture budget ([`super::canvas_budget`]) pages are held to.
pub fn canvas_budget(&self) -> u64 {
self.canvas_budget.load(Ordering::Relaxed)
}
}
@@ -166,6 +166,9 @@ pub(super) struct Tile {
pub(super) levels: Vec<wgpu::TextureView>,
pub(super) bind_group: wgpu::BindGroup,
pub(super) _uniform: wgpu::Buffer,
/// A viewport page ([`GpuCanvas::upload_page`]): its id and reduction factor (document
/// pixels per texel); `None` for a tile of a whole-document texture set.
pub(super) page: Option<(u64, u32)>,
}
fn uniform_entry(binding: u32, size: u64, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
@@ -9,7 +9,7 @@ struct View {
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
h: vec4<f32>, // cos, sin, center_doc_x, center_doc_y
};
struct Tile { r: vec4<f32> }; // x, y, w, h in doc px
struct Tile { r: vec4<f32>, core: vec4<f32> }; // x, y, w, h in doc px; drawn x0, y0, x1, y1
@group(0) @binding(0) var<uniform> view: View;
@group(0) @binding(1) var samp: sampler;
@@ -163,6 +163,8 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
let p = in.pos.xy;
let scale = view.a.z;
let d = px_to_doc(p) - view.d.xy; // document pixel coordinates (within this copy)
// A viewport page's apron is only sampled; its neighbour draws those pixels.
if (any(d < tile.core.xy) || any(d >= tile.core.zw)) { discard; }
let size = tile.r.zw;
let t = d - tile.r.xy; // texel coordinates within this tile
let mode = view.c.x;
+13 -1
View File
@@ -677,11 +677,23 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) {
if let Some(gpu) = &app.gpu {
// Keep each document's texture plus its preview textures (filter preview, adjustment proxy);
// retaining only document ids freed the previews every frame (blank canvas while previewing).
let mut live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
let mut live: Vec<u64> = app
.session
.documents()
.iter()
.flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62), super::viewport_pages::stream_key(st.doc.id.0)])
.collect();
live.extend(crate::adjust_preview::gpu_keys(app));
gpu.retain(&live);
}
crate::background_redraw::retain(app);
// Page work for closed documents (or of a dropped GPU canvas) is cancelled.
if app.gpu.is_none() {
app.streams.clear();
} else {
let ids: Vec<_> = app.session.documents().iter().map(|st| st.doc.id).collect();
app.streams.retain(|id, _| ids.contains(id));
}
// Upload markers must not outlive the resources they describe: native reopen can reuse
// both the document ID and revision before the next frame while a preview dialog stays open.
let documents = app.session.documents();
+942
View File
@@ -0,0 +1,942 @@
//! Viewport pages for a document whose display textures don't fit the GPU canvas budget
//! ([`super::gpu_canvas::GpuCanvas::fits_canvas`], from the detected RAM and VRAM, so also with
//! GPU Memory on Auto). Instead of the whole-document texture set, the view's pages are rendered
//! on two background workers at the view's reduction and uploaded as their own GPU textures,
//! under [`stream_key`]: the work and the GPU memory follow what is on screen, not the document.
//!
//! Documents that fit keep the texture set and everything around it (the layer budget, deferred
//! refresh, background redraw). Without a GPU canvas the CPU canvas draws every document.
//!
//! Pages are derived data: an edit marks the pages its damage reaches dirty (the rest stay) and,
//! for a small one, re-renders just the texels it reaches into the page held; a
//! dirty page stays on screen until its replacement is uploaded, and work for pages that are no
//! longer wanted (a pan, a zoom, an edit) is cancelled.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, mpsc};
use photocraft_doc::Document;
use photocraft_engine::display_color::CanvasDisplay;
use photocraft_geom::Rect as DRect;
use super::textures::texture_buffer;
use super::{ViewXform, canvas_display, damage_since, display_doc, effect_reach, texture_key, visible_doc_rect};
use crate::PhotocraftApp;
/// Document pixels per page side at the page's reduction.
pub(crate) const PAGE_SIDE: u32 = 512;
/// The most pages a view asks for: coarser pages are used beyond it.
const MAX_PAGES: usize = 256;
/// Pages rendering at once, over all views and documents.
const MAX_JOBS: usize = 2;
/// Pages uploaded per frame, and the texel bytes they may take: staging stays bounded however
/// many pages finish at once.
const UPLOADS_PER_FRAME: usize = 2;
const UPLOAD_BYTES_PER_FRAME: u64 = 32 << 20;
/// The coarsest reduction (document pixels per texel).
const MAX_FACTOR: u32 = 1 << 16;
static ACTIVE: AtomicUsize = AtomicUsize::new(0);
/// The GPU canvas key of `doc`'s pages, apart from its texture set and the preview keys (bits 58
/// to 63 tag those: the filter preview, adjustment proxies, gradients and thumbnails).
pub(crate) fn stream_key(doc: u64) -> u64 {
doc ^ (1u64 << 57)
}
/// A page: `factor` document pixels per texel, column `x` and row `y` of `PAGE_SIDE` texels.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct PageId {
pub factor: u32,
pub x: u32,
pub y: u32,
}
impl PageId {
/// The GPU canvas page id.
pub fn id(self) -> u64 {
(u64::from(self.factor.trailing_zeros()) << 56) | (u64::from(self.y) << 28) | u64::from(self.x)
}
/// The document pixels this page draws.
pub fn core(self, size: [u32; 2]) -> DRect {
let side = u64::from(PAGE_SIDE) * u64::from(self.factor);
let (x, y) = (u64::from(self.x) * side, u64::from(self.y) * side);
if self.factor == 0 || x >= u64::from(size[0]) || y >= u64::from(size[1]) {
return DRect::EMPTY;
}
let end = |o: u64, n: u32| (o + side).min(u64::from(n)) as i32;
DRect::new(x as i32, y as i32, end(x, size[0]), end(y, size[1]))
}
/// The document pixels it is rendered from: the core and one texel around it, so filtering
/// at its edge matches its neighbour's.
pub fn source(self, size: [u32; 2]) -> DRect {
let core = self.core(size);
if core.is_empty() {
return core;
}
core.inflate(self.factor as i32).intersect(&DRect::new(0, 0, size[0] as i32, size[1] as i32))
}
}
/// The page reduction for `zoom` (screen pixels per document pixel): the largest power of two at
/// which a texel still covers at most one screen pixel.
pub(crate) fn factor(zoom: f32) -> u32 {
if !zoom.is_finite() || zoom <= 0.0 {
return 1;
}
let mut k = 1u32;
while k < MAX_FACTOR && zoom * (k * 2) as f32 <= 1.0 {
k *= 2;
}
k
}
/// The part of `page` a change to `damage` reaches, widened to whole texels: rendered from just
/// that area, its texels are the ones the whole page has (reductions are globally aligned).
fn partial_region(page: PageId, size: [u32; 2], damage: DRect) -> DRect {
let k = page.factor.max(1) as i32;
let source = page.source(size);
let d = damage.intersect(&source);
if d.is_empty() {
return DRect::EMPTY;
}
let up = |v: i32| v.div_euclid(k).saturating_add(i32::from(v.rem_euclid(k) != 0)).saturating_mul(k);
DRect::new(d.x0.div_euclid(k) * k, d.y0.div_euclid(k) * k, up(d.x1), up(d.y1)).intersect(&source)
}
/// The pages covering `visible` of a `size` document at `zoom`, centre first: at most
/// `max_pages` (coarser when the view needs more).
pub(crate) fn pages(size: [u32; 2], visible: DRect, zoom: f32, max_pages: usize) -> Vec<PageId> {
let visible = visible.intersect(&DRect::new(0, 0, size[0].min(i32::MAX as u32) as i32, size[1].min(i32::MAX as u32) as i32));
if visible.is_empty() || max_pages == 0 {
return Vec::new();
}
let mut k = factor(zoom);
loop {
let side = u64::from(PAGE_SIDE) * u64::from(k);
let span = |a: i32, b: i32| (a as u64 / side, (b as u64).div_ceil(side));
let ((x0, x1), (y0, y1)) = (span(visible.x0, visible.x1), span(visible.y0, visible.y1));
if (x1 - x0) * (y1 - y0) <= max_pages.min(MAX_PAGES) as u64 {
let mut out: Vec<PageId> = (y0..y1).flat_map(|y| (x0..x1).map(move |x| PageId { factor: k, x: x as u32, y: y as u32 })).collect();
let (cx, cy) = ((x0 + x1) as i64, (y0 + y1) as i64);
out.sort_by_key(|p| (i64::from(p.x) * 2 + 1 - cx).abs() + (i64::from(p.y) * 2 + 1 - cy).abs());
return out;
}
if k >= MAX_FACTOR {
return Vec::new();
}
k *= 2;
}
}
/// What a stream's pages show: the document revision, its preview and display keys, its size.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Identity {
pub revision: u64,
/// The raw preview key ([`display_doc`]); `display` is folded in for [`damage_since`].
pub preview: u64,
pub display: u64,
pub size: [u32; 2],
}
/// A page on the GPU.
#[derive(Debug)]
struct Page {
/// Rendered before the latest edit that reached it: shown until its replacement arrives.
dirty: bool,
/// The exact reduction (not the quick preview).
exact: bool,
/// While dirty, the only document area that changed since it was uploaded (aligned to its
/// texels): just that part is rendered again and written into the page. `None`: all of it.
partial: Option<DRect>,
/// Bumped whenever the page's content is invalidated: a result from older work is dropped.
epoch: u64,
touched: u64,
bytes: u64,
}
struct Job {
page: PageId,
epoch: u64,
cancel: Arc<AtomicBool>,
}
/// A finished page render.
pub(crate) struct Done {
pub page: PageId,
pub epoch: u64,
pub exact: bool,
/// Rendered for [`Page::partial`]: written into the page held, not a page of its own.
pub region: Option<DRect>,
pub result: Result<photocraft_compose::Buffer, String>,
}
/// The counters `inspect` reports.
#[derive(Clone, Debug, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Stats {
pub wanted: usize,
pub ready: usize,
pub resident: usize,
pub resident_bytes: u64,
pub limit: u64,
pub jobs: usize,
pub rendered: u64,
pub uploads: u64,
pub cancelled: u64,
pub dirtied: u64,
/// Pages updated in part, by re-rendering only what an edit reached.
pub partial: u64,
pub evicted: u64,
pub last_error: Option<String>,
}
/// One document's viewport pages ([`PhotocraftApp::streams`]).
pub(crate) struct Stream {
pages: HashMap<PageId, Page>,
/// Epochs of pages not (or no longer) on the GPU, so their results can still be checked.
epochs: HashMap<PageId, u64>,
jobs: Vec<Job>,
ready: Vec<Done>,
failed: HashSet<(PageId, u64)>,
tx: mpsc::Sender<Done>,
rx: mpsc::Receiver<Done>,
pub seen: Option<Identity>,
clock: u64,
/// The page ids last given to the GPU canvas, to tell it only of changes.
synced: Vec<u64>,
health: (u64, u64),
pub stats: Stats,
}
impl Default for Stream {
fn default() -> Self {
let (tx, rx) = mpsc::channel();
Self {
pages: HashMap::new(),
epochs: HashMap::new(),
jobs: Vec::new(),
ready: Vec::new(),
failed: HashSet::new(),
tx,
rx,
seen: None,
clock: 0,
synced: Vec::new(),
health: (0, 0),
stats: Stats::default(),
}
}
}
impl Drop for Stream {
fn drop(&mut self) {
for j in &self.jobs {
j.cancel.store(true, Ordering::Relaxed);
}
}
}
impl Stream {
fn epoch(&self, page: PageId) -> u64 {
self.pages.get(&page).map_or_else(|| self.epochs.get(&page).copied().unwrap_or(0), |p| p.epoch)
}
fn bump(&mut self, page: PageId) {
let e = self.epoch(page) + 1;
match self.pages.get_mut(&page) {
Some(p) => p.epoch = e,
None => {
self.epochs.insert(page, e);
}
}
}
/// The document changed to `now`: the pages whose source `damage` reaches (every page
/// without it) are dirty, and work on them is cancelled. Nothing else is scanned: the cost
/// is the pages held, not the document's layers or tiles.
pub fn invalidate(&mut self, now: Identity, damage: Option<DRect>) {
let size_changed = self.seen.is_some_and(|s| s.size != now.size);
self.seen = Some(now);
if size_changed {
self.reset();
return;
}
let hit = |p: PageId| damage.is_none_or(|d| !p.source(now.size).intersect(&d).is_empty());
let pages: Vec<PageId> = self.pages.keys().copied().filter(|p| hit(*p)).collect();
for p in &pages {
if let Some(page) = self.pages.get_mut(p) {
// An exact page that is current, or only partly stale, takes the new damage too.
let partial = damage.filter(|_| page.exact && (!page.dirty || page.partial.is_some())).and_then(|d| {
let r = page.partial.unwrap_or(DRect::EMPTY).union(&partial_region(*p, now.size, d));
let source = p.source(now.size);
// Most of the page: rendering it whole is no slower.
(r.width() as u64 * r.height() as u64 * 2 <= source.width() as u64 * source.height() as u64).then_some(r)
});
page.dirty = true;
page.exact &= partial.is_some();
page.partial = partial;
}
self.bump(*p);
}
self.stats.dirtied += pages.len() as u64;
let jobs: Vec<PageId> = self.jobs.iter().map(|j| j.page).filter(|p| hit(*p)).collect();
for p in jobs {
if !self.pages.contains_key(&p) {
self.bump(p);
}
}
self.cancel_stale();
self.ready.retain(|d| !hit(d.page));
self.failed.retain(|(p, _)| !hit(*p));
}
/// Forget every page (the GPU canvas lost them, or the document changed size).
pub fn reset(&mut self) {
let pages: Vec<PageId> = self.pages.keys().chain(self.jobs.iter().map(|j| &j.page)).copied().collect();
for p in pages {
let e = self.epoch(p) + 1;
self.epochs.insert(p, e);
}
self.pages.clear();
self.ready.clear();
self.failed.clear();
self.synced.clear();
self.cancel_stale();
}
/// Cancel jobs whose page moved to a newer epoch.
fn cancel_stale(&mut self) {
for j in &self.jobs {
if j.epoch != self.epoch(j.page) && !j.cancel.swap(true, Ordering::Relaxed) {
self.stats.cancelled += 1;
}
}
}
/// Take finished work, cancel work for pages not `wanted`, and start work for wanted pages
/// that are missing, dirty or still a quick preview (`faithful`: the document has a quick
/// preview at reductions), as `spawn(page, epoch, exact, cancel, region)` allows: `region`, the
/// part of a held page to render again ([`Page::partial`]), or the whole page.
pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc<AtomicBool>, Option<DRect>) -> bool) {
self.clock += 1;
let want: HashSet<PageId> = wanted.iter().copied().collect();
while let Ok(d) = self.rx.try_recv() {
let Some(i) = self.jobs.iter().position(|j| j.page == d.page && j.epoch == d.epoch) else { continue };
// Cancelled work ends in an interruption, not a failure: the page is asked for again.
let cancelled = self.jobs.swap_remove(i).cancel.load(Ordering::Relaxed);
if cancelled || d.epoch != self.epoch(d.page) || !want.contains(&d.page) {
continue;
}
// A part of a page no longer held (evicted meanwhile): the whole page is asked for.
if d.region.is_some() && !self.pages.get(&d.page).is_some_and(|q| q.dirty && q.partial == d.region) {
continue;
}
match &d.result {
Ok(_) => {
self.stats.rendered += 1;
self.ready.retain(|r| r.page != d.page);
self.ready.push(d);
}
Err(e) => {
log::warn!("viewport page {:?}: {e}", d.page);
self.stats.last_error = Some(e.clone());
self.failed.insert((d.page, d.epoch));
}
}
}
for j in &self.jobs {
if !want.contains(&j.page) && !j.cancel.swap(true, Ordering::Relaxed) {
self.stats.cancelled += 1;
}
}
self.ready.retain(|d| want.contains(&d.page));
for p in wanted {
if let Some(page) = self.pages.get_mut(p) {
page.touched = self.clock;
}
}
let current = |s: &Self, p: &PageId| s.pages.get(p).is_some_and(|q| !q.dirty) || s.ready.iter().any(|d| d.page == *p);
let all_shown = wanted.iter().all(|p| current(self, p));
for p in wanted {
let live = self.jobs.iter().filter(|j| !j.cancel.load(Ordering::Relaxed)).count();
if live >= MAX_JOBS {
break;
}
let epoch = self.epoch(*p);
if self.jobs.iter().any(|j| j.page == *p && j.epoch == epoch) || self.failed.contains(&(*p, epoch)) {
continue;
}
// First every page in view (a quick preview at a reduction), then the exact ones.
let exact = p.factor == 1 || !faithful || all_shown;
let have = self.ready.iter().find(|d| d.page == *p).map(|d| d.exact).or_else(|| self.pages.get(p).filter(|q| !q.dirty).map(|q| q.exact));
if have.is_some_and(|e| e || !exact) {
continue;
}
let region = self.pages.get(p).filter(|q| q.dirty).and_then(|q| q.partial);
let cancel = Arc::new(AtomicBool::new(false));
if !spawn(*p, epoch, exact || region.is_some(), cancel.clone(), region) {
break;
}
self.jobs.push(Job { page: *p, epoch, cancel });
}
self.stats.wanted = wanted.len();
self.stats.ready = wanted.iter().filter(|p| self.pages.contains_key(p)).count();
self.stats.jobs = self.jobs.len();
}
/// Retry failed pages once the scratch storage or memory configuration changed (`health`).
pub fn retry_after(&mut self, health: (u64, u64)) {
if health != self.health {
self.health = health;
self.failed.clear();
}
}
/// Whether a wanted page can't be shown: its render or upload failed and nothing older of it
/// is held, so the view would have a hole (the CPU canvas draws instead).
pub fn stuck(&self, wanted: &[PageId]) -> bool {
wanted.iter().any(|p| !self.pages.contains_key(p) && self.failed.contains(&(*p, self.epoch(*p))))
}
/// Whether work is outstanding (the view repaints until it is done).
pub fn busy(&self) -> bool {
!self.jobs.is_empty() || !self.ready.is_empty()
}
/// The finished pages to upload this frame: at most [`UPLOADS_PER_FRAME`], and
/// [`UPLOAD_BYTES_PER_FRAME`] of texels (`texel_bytes` each), the first always.
pub fn take_uploads(&mut self, wanted: &[PageId], texel_bytes: u64) -> Vec<Done> {
let order = |d: &Done| wanted.iter().position(|p| *p == d.page).unwrap_or(usize::MAX);
self.ready.sort_by_key(order);
let mut out = Vec::new();
let mut bytes = 0u64;
while out.len() < UPLOADS_PER_FRAME && !self.ready.is_empty() {
let b = self.ready[0].result.as_ref().map_or(0, |b| b.px.len() as u64 * texel_bytes);
if !out.is_empty() && bytes + b > UPLOAD_BYTES_PER_FRAME {
break;
}
bytes += b;
out.push(self.ready.remove(0));
}
out
}
/// Make room for `bytes` more under `limit` by evicting the least recently wanted pages not
/// in `wanted`: whether any was evicted, or `None` when the wanted pages leave no room.
pub fn make_room(&mut self, page: PageId, bytes: u64, limit: u64, wanted: &[PageId]) -> Option<bool> {
let held = |s: &Self| s.pages.iter().filter(|(p, _)| **p != page).map(|(_, q)| q.bytes).sum::<u64>();
let mut evicted = false;
while held(self) + bytes > limit {
let cold = self.pages.iter().filter(|(p, _)| **p != page && !wanted.contains(p)).min_by_key(|(_, q)| q.touched).map(|(p, _)| *p)?;
self.drop_page(cold);
self.stats.evicted += 1;
evicted = true;
}
Some(evicted)
}
/// The GPU page ids of the pages held.
pub fn ids(&self) -> Vec<u64> {
self.pages.keys().map(|p| p.id()).collect()
}
fn drop_page(&mut self, p: PageId) {
if let Some(q) = self.pages.remove(&p) {
self.epochs.insert(p, q.epoch);
}
}
/// `done` is on the GPU (`ok`), or couldn't be stored.
pub fn uploaded(&mut self, done: &Done, bytes: u64, ok: bool) {
if !ok {
self.stats.last_error = Some("the GPU canvas couldn't store a viewport page".into());
self.failed.insert((done.page, done.epoch));
return;
}
self.stats.uploads += 1;
self.epochs.remove(&done.page);
if done.region.is_some()
&& let Some(page) = self.pages.get_mut(&done.page)
{
(page.dirty, page.partial, page.epoch, page.touched) = (false, None, done.epoch, self.clock);
self.stats.partial += 1;
return;
}
self.pages.insert(done.page, Page { dirty: false, exact: done.exact, partial: None, epoch: done.epoch, touched: self.clock, bytes });
}
/// The part of `page` written by `done` couldn't be stored: drop the page, so it is
/// rendered whole.
pub fn partial_failed(&mut self, page: PageId) {
self.drop_page(page);
}
/// Drop pages no longer worth keeping: once every wanted page is shown and current, the
/// dirty ones and the ones finer than the view (they would draw over it). Coarser clean
/// pages stay (under the view's own, for a quick pan or zoom out) until evicted. The GPU
/// page ids to keep when they changed since the last call.
pub fn prune(&mut self, wanted: &[PageId]) -> Option<Vec<u64>> {
let all_current = wanted.iter().all(|p| self.pages.get(p).is_some_and(|q| !q.dirty));
if all_current && let Some(f) = wanted.first().map(|p| p.factor) {
let drop: Vec<PageId> = self.pages.iter().filter(|(p, q)| !wanted.contains(p) && (q.dirty || p.factor < f)).map(|(p, _)| *p).collect();
for p in drop {
self.drop_page(p);
}
}
// Epochs only guard results still on their way.
let jobs: HashSet<PageId> = self.jobs.iter().map(|j| j.page).collect();
self.epochs.retain(|p, _| jobs.contains(p));
let mut ids = self.ids();
ids.sort_unstable();
self.stats.resident = ids.len();
self.stats.resident_bytes = self.pages.values().map(|q| q.bytes).sum();
(ids != self.synced).then(|| {
self.synced = ids.clone();
ids
})
}
/// The pages this stream believes are on the GPU.
pub fn resident(&self) -> usize {
self.pages.len()
}
}
/// Render `page` of `doc` on a worker, if one of the [`MAX_JOBS`] is free.
fn spawn(
ctx: &egui::Context,
tx: mpsc::Sender<Done>,
doc: Arc<Document>,
display: Option<Arc<CanvasDisplay>>,
(page, epoch, exact, region): (PageId, u64, bool, Option<DRect>),
cancel: Arc<AtomicBool>,
) -> bool {
if ACTIVE.fetch_add(1, Ordering::AcqRel) >= MAX_JOBS {
ACTIVE.fetch_sub(1, Ordering::AcqRel);
return false;
}
struct Slot(egui::Context);
impl Drop for Slot {
fn drop(&mut self) {
ACTIVE.fetch_sub(1, Ordering::AcqRel);
// A view waiting for a free worker (another document's) can start now.
self.0.request_repaint();
}
}
let slot = Slot(ctx.clone());
let work = move || {
let _slot = slot;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&doc, page, exact, region, display.as_deref(), &cancel)))
.unwrap_or_else(|_| Err("viewport page render panicked".into()));
let _ = tx.send(Done { page, epoch, exact, region, result });
};
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::Builder::new().name("pc-viewport-page".into()).spawn(work).is_ok()
}
#[cfg(target_arch = "wasm32")]
{
work();
true
}
}
/// One page as the GPU canvas stores it (encoded for the display like the texture set).
fn render(
doc: &Document,
page: PageId,
exact: bool,
region: Option<DRect>,
display: Option<&CanvasDisplay>,
cancel: &AtomicBool,
) -> Result<photocraft_compose::Buffer, String> {
let size = [doc.size.width, doc.size.height];
let source = region.unwrap_or_else(|| page.source(size));
let rows = u64::from(source.height().min(128));
let _working = photocraft_raster::memory::try_reserve(u64::from(source.width()) * rows * 16 + (16 << 20))?;
let before = photocraft_raster::spill::read_error_generation();
let check = || cancel.load(Ordering::Relaxed);
let i = photocraft_raster::Interrupt::cancel_only(&check);
let buf = if exact {
photocraft_compose::viewport::render_page(doc, source, page.factor, i)?
} else {
photocraft_compose::viewport::preview_page(doc, source, page.factor, i)?
};
i.check().map_err(|e| e.to_string())?;
photocraft_raster::spill::check_since(before)?;
Ok(texture_buffer(display, &buf).into_owned())
}
/// Draw document `idx` from viewport pages when its texture set doesn't fit the GPU canvas
/// budget: true when the view should paint [`stream_key`] with the GPU canvas.
pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: &ViewXform, zoom: f32) -> bool {
let Some(gpu) = app.gpu.clone() else { return false };
if app.ui.view.pattern_preview || gpu.fault().is_some() {
return false;
}
let Some((revision, last_damage, id, size)) = 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, st.doc.size))
else {
return false;
};
let size = [size.width, size.height];
let (doc, raw_key) = display_doc(app, idx);
// A size-changing preview draws as main does; a document that fits keeps its texture set.
if [doc.size.width, doc.size.height] != size || gpu.fits_canvas(&doc) {
if let Some(s) = app.streams.remove(&id) {
drop(s);
gpu.retain_pages(stream_key(id.0), &[]);
}
return false;
}
let (display, _) = canvas_display(app, &doc, None);
let display_key = texture_key(display.as_deref());
let now = Identity { revision, preview: raw_key, display: display_key, size };
let seen = app.streams.get(&id).and_then(|s| s.seen);
if seen != Some(now) {
let damage = seen
.filter(|s| s.size == size && s.display == display_key)
.and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage));
app.streams.entry(id).or_default().invalidate(now, damage);
}
let key = stream_key(id.0);
// Half the per-document canvas budget: the rest is headroom for what the compositor needs.
let limit = gpu.canvas_budget() / 2;
let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1);
let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX);
let wanted = pages(size, visible_doc_rect(xf), zoom, max_pages);
if gpu.texture_info(id.0).is_some() {
// Switching from the texture set: free it, the pages are budgeted against that memory.
gpu.release_texture_set(id.0);
}
let stream = app.streams.entry(id).or_default();
stream.retry_after((photocraft_raster::spill::read_error_generation(), photocraft_raster::memory::configuration_generation()));
// No room for a single page, or a page that failed: the CPU canvas draws the document, as
// main does for every document over the budget.
if max_pages == 0 || stream.stuck(&wanted) {
return false;
}
let faithful = photocraft_compose::proxy::proxy_faithful(&doc);
let texel_bytes = gpu.page_texture_bytes(doc.depth, size, 1, 1);
// The GPU canvas dropped the pages (released, or pruned with a closed document): start over.
if stream.resident() > 0 && gpu.pages(key).0.len() != stream.resident() {
stream.reset();
}
// Drawn from pages: no texture set, so no layer budget or background redraw applies.
crate::background_redraw::cancel(app, id);
if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) {
app.perf.gpu_layer_fit = None;
}
app.navigator_gpu.remove(&id);
crate::render_path::note(app, id.0, crate::render_path::Path::Gpu { full: true });
let stream = app.streams.entry(id).or_default();
let tx = stream.tx.clone();
stream.update(&wanted, faithful, |page, epoch, exact, cancel, region| {
spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact, region), cancel)
});
for d in stream.take_uploads(&wanted, texel_bytes) {
let Ok(buf) = &d.result else { continue };
if d.region.is_some() {
if gpu.update_page(key, d.page.id(), buf) {
stream.uploaded(&d, 0, true);
} else {
stream.partial_failed(d.page);
gpu.retain_pages(key, &stream.ids());
}
continue;
}
let bytes = buf.px.len() as u64 * texel_bytes;
let ok = match stream.make_room(d.page, bytes, limit, &wanted) {
None => false,
Some(evicted) => {
if evicted {
gpu.retain_pages(key, &stream.ids());
}
gpu.upload_page(crate::gpu_canvas::PageUpload {
key,
page: d.page.id(),
size,
depth: doc.depth,
factor: d.page.factor,
core: d.page.core(size),
buf,
})
}
};
stream.uploaded(&d, bytes, ok);
}
if let Some(ids) = stream.prune(&wanted) {
gpu.retain_pages(key, &ids);
}
stream.stats.limit = limit;
if stream.busy() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
true
}
#[cfg(test)]
mod tests {
use super::*;
const SIZE: [u32; 2] = [20_000, 12_000];
fn id(revision: u64) -> Identity {
Identity { revision, preview: 1, display: 2, size: SIZE }
}
fn page(factor: u32, x: u32, y: u32) -> PageId {
PageId { factor, x, y }
}
fn done(p: PageId, epoch: u64, exact: bool, side: i32) -> Done {
let rect = DRect::new(0, 0, side, side);
Done { page: p, epoch, exact, region: None, result: Ok(photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; (side * side) as usize] }) }
}
/// A stream holding `held` clean, exact pages.
fn holding(held: &[PageId]) -> Stream {
let mut s = Stream::default();
s.invalidate(id(1), None);
for p in held {
s.uploaded(&done(*p, s.epoch(*p), true, 4), 100, true);
}
s
}
/// Spawns every job it is offered, recording it with its cancel flag.
fn recorder(log: &mut Vec<(PageId, u64, bool, Arc<AtomicBool>)>) -> impl FnMut(PageId, u64, bool, Arc<AtomicBool>, Option<DRect>) -> bool + '_ {
|p, e, x, c, _| {
log.push((p, e, x, c));
true
}
}
#[test]
fn factor_keeps_a_texel_at_most_one_screen_pixel() {
assert_eq!(factor(4.0), 1);
assert_eq!(factor(1.0), 1);
assert_eq!(factor(0.5), 2);
assert_eq!(factor(0.3), 2);
assert_eq!(factor(0.25), 4);
assert_eq!(factor(0.0), 1);
assert_eq!(factor(f32::NAN), 1);
assert_eq!(factor(1e-9), MAX_FACTOR);
}
#[test]
fn pages_cover_the_view_centre_first_and_coarsen_past_the_limit() {
let visible = DRect::new(1000, 1000, 2500, 2000);
let got = pages(SIZE, visible, 1.0, 64);
// Columns 1..=4, rows 1..=3 of 512 px pages.
assert_eq!(got.len(), 4 * 3);
assert!(got.iter().all(|p| p.factor == 1));
let union = got.iter().fold(DRect::EMPTY, |u, p| u.union(&p.core(SIZE)));
assert_eq!(union.intersect(&visible), visible, "every visible pixel is on a page");
let centre = got[0].core(SIZE);
assert!(centre.inflate(512).contains(1750, 1500), "centre first: {:?}", got[0]);
// The whole document at 1:1 is 40×24 pages: over the limit it uses a reduction.
let whole = DRect::new(0, 0, SIZE[0] as i32, SIZE[1] as i32);
let coarse = pages(SIZE, whole, 1.0, 64);
assert!(!coarse.is_empty() && coarse.len() <= 64);
assert!(coarse.iter().all(|p| p.factor == 4), "{:?}", coarse[0]);
assert!(pages(SIZE, whole, 1.0, 0).is_empty());
assert!(pages(SIZE, DRect::new(-500, -500, -1, -1), 1.0, 64).is_empty(), "off the document");
}
#[test]
fn a_page_core_tiles_the_document_and_its_source_adds_one_texel() {
let p = page(2, 1, 0);
assert_eq!(p.core(SIZE), DRect::new(1024, 0, 2048, 1024));
assert_eq!(p.source(SIZE), DRect::new(1022, 0, 2050, 1026));
// The last column is clipped to the document.
let last = page(1, SIZE[0] / 512, 0);
assert_eq!(last.core(SIZE).x1, SIZE[0] as i32);
assert!(page(1, 1000, 0).core(SIZE).is_empty());
// Ids differ across factors and positions.
assert_ne!(page(1, 1, 0).id(), page(2, 1, 0).id());
assert_ne!(page(1, 1, 0).id(), page(1, 0, 1).id());
}
#[test]
fn an_edit_dirties_only_the_pages_it_reaches() {
let (a, b, c) = (page(1, 0, 0), page(1, 1, 0), page(1, 4, 4));
let mut s = holding(&[a, b, c]);
// Damage on page a's right edge also reaches b's one-texel apron.
s.invalidate(id(2), Some(DRect::new(500, 10, 512, 20)));
assert!(s.pages[&a].dirty && s.pages[&b].dirty);
assert!(!s.pages[&c].dirty && s.pages[&c].exact, "far page untouched");
assert_eq!(s.stats.dirtied, 2);
// A dirty page stays on screen until replaced; no damage known dirties every page.
s.invalidate(id(3), None);
assert_eq!(s.pages.len(), 3);
assert!(s.pages.values().all(|p| p.dirty));
// A new size starts over.
s.invalidate(Identity { size: [100, 100], ..id(4) }, None);
assert_eq!(s.resident(), 0);
}
#[test]
fn pan_zoom_and_edits_cancel_obsolete_work() {
let mut s = holding(&[]);
let mut log = Vec::new();
let view = [page(1, 0, 0), page(1, 1, 0), page(1, 2, 0)];
s.update(&view, true, recorder(&mut log));
assert_eq!(log.len(), MAX_JOBS, "bounded workers");
assert!(log.iter().all(|j| j.2), "1:1 pages are rendered exactly");
// Pan: the first page is no longer wanted.
s.update(&view[1..], true, recorder(&mut Vec::new()));
assert!(log[0].3.load(Ordering::Relaxed), "work for a page out of view is cancelled");
assert!(!log[1].3.load(Ordering::Relaxed));
// Edit on the second page: its result would be stale.
s.invalidate(id(2), Some(DRect::new(600, 0, 601, 1)));
assert!(log[1].3.load(Ordering::Relaxed), "work on an edited page is cancelled");
// Its old result is dropped when it arrives, and so is a cancelled job's interruption.
s.tx.send(Done { page: log[0].0, epoch: log[0].1, exact: true, region: None, result: Err("interrupted".into()) }).unwrap();
s.tx.send(done(log[1].0, log[1].1, true, 4)).unwrap();
s.update(&view[1..], true, |_, _, _, _, _| false);
assert!(s.ready.is_empty(), "stale result dropped");
assert!(!s.stuck(&view) && s.stats.last_error.is_none(), "a cancellation is not a failure");
// Zoom: every page changes factor.
let mut log2 = Vec::new();
s.update(&[page(1, 1, 0)], true, recorder(&mut log2));
let jobs = s.jobs.len();
s.update(&[page(2, 0, 0)], true, |_, _, _, _, _| true);
assert!(log2.iter().all(|j| j.3.load(Ordering::Relaxed)), "zoom cancels the old factor's work");
assert!(s.stats.cancelled >= 3 && jobs > 0);
}
#[test]
fn reductions_show_a_quick_preview_before_the_exact_page() {
let mut s = holding(&[]);
let view = [page(2, 0, 0)];
let mut log = Vec::new();
s.update(&view, true, recorder(&mut log));
assert_eq!(log.len(), 1);
assert!(!log[0].2, "a faithful document previews first");
s.tx.send(done(view[0], log[0].1, false, 4)).unwrap();
let mut log2 = Vec::new();
s.update(&view, true, recorder(&mut log2));
assert!(log2.len() == 1 && log2[0].2, "then the exact page, once every page is shown");
let mut log3 = Vec::new();
holding(&[]).update(&view, false, recorder(&mut log3));
assert!(log3[0].2, "an unfaithful preview is skipped");
}
#[test]
fn a_small_edit_re_renders_only_the_texels_it_reaches() {
let (a, b) = (page(4, 1, 1), page(4, 3, 3));
let mut s = holding(&[a, b]);
// Page a: core 2048..4096 at 4 px per texel, source one texel more.
s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111)));
assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2112, 2112)), "whole texels");
assert!(s.pages[&a].dirty && s.pages[&a].exact);
// More damage on a dirty page adds to its part.
s.invalidate(id(3), Some(DRect::new(2200, 2100, 2204, 2104)));
assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2204, 2112)));
let mut regions = Vec::new();
s.update(&[a], true, |p, e, x, _, r| {
regions.push((p, e, x, r));
true
});
assert_eq!(regions, vec![(a, s.epoch(a), true, s.pages[&a].partial)]);
// The part is written into the page held: same page, same bytes, current again.
let mut d = done(a, regions[0].1, true, 4);
d.region = regions[0].3;
s.tx.send(d).unwrap();
s.update(&[a], true, |_, _, _, _, _| panic!("nothing left to render"));
let up = s.take_uploads(&[a], 16);
assert_eq!(up.len(), 1);
s.uploaded(&up[0], 0, true);
assert!(!s.pages[&a].dirty && s.pages[&a].partial.is_none() && s.pages[&a].bytes == 100);
assert_eq!(s.stats.partial, 1);
// Most of a page, or damage of unknown extent, renders it whole.
s.invalidate(id(4), Some(DRect::new(2048, 2048, 4000, 4000)));
assert!(s.pages[&a].dirty && s.pages[&a].partial.is_none() && !s.pages[&a].exact);
s.invalidate(id(5), None);
assert!(s.pages[&b].partial.is_none());
// A part of a page evicted meanwhile is dropped: the whole page is asked for.
let mut s = holding(&[a]);
s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111)));
let region = s.pages[&a].partial;
let epoch = s.epoch(a);
s.partial_failed(a);
let mut d = done(a, epoch, true, 4);
d.region = region;
s.tx.send(d).unwrap();
let mut log = Vec::new();
s.update(&[a], true, recorder(&mut log));
assert!(s.ready.is_empty());
assert_eq!(log.len(), 1, "the whole page");
}
#[test]
fn uploads_per_frame_are_bounded() {
let mut s = holding(&[]);
let view: Vec<PageId> = (0..4).map(|x| page(1, x, 0)).collect();
for p in &view {
s.ready.push(done(*p, 0, true, 514));
}
// 514² texels of 16 bytes: 4.2 MB each.
let first = s.take_uploads(&view, 16);
assert_eq!(first.len(), UPLOADS_PER_FRAME);
assert_eq!(first[0].page, view[0], "in view order");
// A page over the byte cap still goes alone.
let big = s.take_uploads(&view, 1 << 10);
assert_eq!(big.len(), 1);
assert_eq!(s.ready.len(), 1);
}
#[test]
fn the_budget_evicts_cold_pages_but_never_wanted_ones() {
let held: Vec<PageId> = (0..4).map(|x| page(1, x, 0)).collect();
let mut s = holding(&held);
s.clock = 10;
s.pages.get_mut(&held[2]).unwrap().touched = 9;
let wanted = [held[0], held[1], page(1, 9, 9)];
// 400 held, limit 450: room for 100 more means evicting one cold page, the oldest.
assert_eq!(s.make_room(wanted[2], 100, 450, &wanted), Some(true));
assert!(!s.pages.contains_key(&held[3]) && s.pages.contains_key(&held[2]));
assert_eq!(s.make_room(wanted[2], 100, 399, &wanted), Some(true));
assert_eq!(s.resident(), 2);
assert_eq!(s.make_room(wanted[2], 100, 250, &wanted), None, "wanted pages are never evicted");
assert_eq!(s.stats.evicted, 2);
}
#[test]
fn a_failed_page_falls_back_until_it_is_retried() {
let mut s = holding(&[]);
let view = [page(1, 0, 0)];
let mut log = Vec::new();
s.update(&view, true, recorder(&mut log));
s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, region: None, result: Err("out of memory".into()) }).unwrap();
s.update(&view, true, |_, _, _, _, _| panic!("no retry while failed"));
assert!(s.stuck(&view));
assert_eq!(s.stats.last_error.as_deref(), Some("out of memory"));
s.retry_after((7, 7));
assert!(!s.stuck(&view), "a new memory configuration retries");
s.invalidate(id(2), None);
assert!(!s.stuck(&view));
}
#[test]
fn prune_drops_finer_and_dirty_pages_once_the_view_is_current() {
let (fine, coarse, view) = (page(1, 0, 0), page(4, 0, 0), page(2, 0, 0));
let mut s = holding(&[fine, coarse]);
assert!(s.prune(&[view]).is_some());
assert_eq!(s.resident(), 2, "kept until the view's own page is shown");
s.uploaded(&done(view, s.epoch(view), true, 4), 100, true);
let ids = s.prune(&[view]).expect("changed");
assert_eq!(ids.len(), 2);
assert!(!s.pages.contains_key(&fine) && s.pages.contains_key(&coarse));
assert!(s.prune(&[view]).is_none(), "unchanged: nothing to tell the GPU");
assert!(s.epochs.is_empty(), "epochs only for work in flight");
}
}
+2
View File
@@ -154,6 +154,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) {
app.gpu_fault = Some(fault.clone());
// Canvas caches pointed at GPU textures: rebuild them as egui textures.
app.canvases.clear();
// Viewport pages lived on the GPU too: their work stops, and the CPU canvas draws instead.
app.streams.clear();
app.proxy_uploaded = None;
app.prefs_rt.gpu_style = None;
app.perf.gpu = false;
+2
View File
@@ -350,6 +350,8 @@ pub struct PhotocraftApp {
navigator_gpu: HashMap<DocId, crate::gpu_canvas::ViewParams>,
/// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`).
navigator_plain: std::collections::HashSet<DocId>,
/// Viewport pages of documents too large for their GPU texture set (`canvas::viewport_pages`).
streams: HashMap<DocId, canvas::viewport_pages::Stream>,
/// The Info panel's Color Sampler readouts for (document, revision) (`panels::sampler_readouts`).
info_samplers: Option<panels::SamplerReadouts>,
/// Display profile readings (#569).
+35
View File
@@ -0,0 +1,35 @@
# Viewport pages (GPU canvas for over-budget documents)
A document's display texture set (every tile, every mip level) is held to the per-document
canvas budget (`gpu_canvas::budget::canvas_budget`: a quarter of physical memory, capped by a
discrete GPU's own memory, from the detected RAM and VRAM, so also with GPU Memory on Auto).
A document whose texture set fits keeps it, with everything around it unchanged: the layer
budget (LayerFit), deferred refreshes (`DEFER_PX`), background redraw and the compositor's
output admission. There is no pixel-count cutoff.
A document whose texture set doesn't fit used to fall back to the CPU canvas
(`canvasTooLarge`). It is now drawn on the GPU canvas from viewport pages
(`ui-egui/src/canvas/viewport_pages.rs`, `gpu_canvas/pages.rs`):
- Pages are 512 texels square at a power-of-two reduction chosen so a texel covers at most one
screen pixel; a view asks for at most 256 pages and coarsens beyond that. Each page is rendered
from its core plus a one-texel apron (`photocraft_compose::viewport`), so independently rendered
pages filter their shared edges identically; the shader discards the apron.
- Two background workers render pages (over all documents), centre of the view first. At a
reduction, a faithful document shows a quick nearest-sample preview of every page in view first,
then the exact (area-averaged) page. Work for a page that left the view, changed zoom or was
edited is cancelled, and a late result of older work is dropped.
- Uploads are bounded per frame (2 pages, 32 MB of texels), so staging stays bounded however
many pages finish at once.
- Pages are held to half the per-document canvas budget (the rest is headroom for the
compositor); the least recently wanted pages are evicted first and wanted pages never are.
The global wgpu resource budget and the split of the GPU memory budget between documents are
not changed.
- An edit dirties only the pages its damage (plus the layers' effect reach) touches; a dirty page
stays on screen until its replacement is uploaded. Invalidation costs the pages held, not the
document's layers or tiles.
- When no page fits the budget, or a page fails to render or upload, the document is drawn on the
CPU canvas as before (`canvasTooLarge`). A failed page is retried when the scratch storage or
memory configuration changes. Closing the document, a GPU fault or dropping the GPU canvas
cancels its work and frees its pages. Pattern Preview and size-changing previews keep main's
path.